From 94718433035347198e18e611497f211fc33088e3 Mon Sep 17 00:00:00 2001 From: Chris Mellor Date: Sat, 16 May 2026 21:26:11 +0100 Subject: [PATCH 1/6] feat(entities): scaffold configurable PK type (bigint/uuid/ulid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof-of-shape for the (b) half of UUID/ULID support: lets host apps choose the primary-key column type for the package's own entities so package IDs can be exposed as UUIDs/ULIDs on a public API surface (christoph-kluge's ask on PR #124). Wires: - `level-up.entities.id_type` config (default `bigint`, accepts `uuid`/`ulid`) - `Blueprint::entityId()` / `Blueprint::entityForeignId()` macros registered in `LevelUpServiceProvider::bootingPackage()`. Now safe in tests after PR #125 moved package migrations to run after providers boot. - `Concerns\HasConfigurableIds` trait — composes Laravel's `HasUniqueIds`, reads config at runtime, no-ops in bigint mode. - Applied to the `Experience` model + `create_experiences_table` stub as the shape proof; remaining 12 models + migrations follow next. BC: with default config, the trait's `uniqueIds()` returns `[]` so `HasUniqueIds`' creating-hook no-ops, `getKeyType()` returns `int`, and `getIncrementing()` returns `true` — byte-identical to today. Existing installs see no change. --- config/level-up.php | 19 ++++++++ .../create_experiences_table.php.stub | 4 +- src/Concerns/HasConfigurableIds.php | 44 +++++++++++++++++++ src/LevelUpServiceProvider.php | 39 ++++++++++++++++ src/Models/Experience.php | 3 ++ 5 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/Concerns/HasConfigurableIds.php diff --git a/config/level-up.php b/config/level-up.php index e69df10..f6e6b98 100644 --- a/config/level-up.php +++ b/config/level-up.php @@ -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 diff --git a/database/migrations/create_experiences_table.php.stub b/database/migrations/create_experiences_table.php.stub index 2927e36..dbafee3 100644 --- a/database/migrations/create_experiences_table.php.stub +++ b/database/migrations/create_experiences_table.php.stub @@ -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(); }); diff --git a/src/Concerns/HasConfigurableIds.php b/src/Concerns/HasConfigurableIds.php new file mode 100644 index 0000000..16fba70 --- /dev/null +++ b/src/Concerns/HasConfigurableIds.php @@ -0,0 +1,44 @@ +packageIdType() === 'bigint' ? 'int' : 'string'; + } + + public function getIncrementing(): bool + { + return $this->packageIdType() === 'bigint'; + } + + public function newUniqueId(): string + { + return match ($this->packageIdType()) { + 'ulid' => strtolower((string) Str::ulid()), + 'uuid' => (string) Str::uuid(), + default => '', + }; + } + + public function uniqueIds(): array + { + return $this->packageIdType() === 'bigint' + ? [] + : [$this->getKeyName()]; + } + + protected function packageIdType(): string + { + return config('level-up.entities.id_type', 'bigint'); + } +} diff --git a/src/LevelUpServiceProvider.php b/src/LevelUpServiceProvider.php index 0a224f4..108d760 100644 --- a/src/LevelUpServiceProvider.php +++ b/src/LevelUpServiceProvider.php @@ -4,6 +4,9 @@ namespace LevelUp\Experience; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\ForeignIdColumnDefinition; +use InvalidArgumentException; use LevelUp\Experience\Providers\EventServiceProvider; use LevelUp\Experience\Services\LeaderboardService; use Spatie\LaravelPackageTools\Package; @@ -11,6 +14,11 @@ class LevelUpServiceProvider extends PackageServiceProvider { + public function bootingPackage(): void + { + $this->registerEntityKeyMacros(); + } + public function configurePackage(Package $package): void { $package @@ -47,4 +55,35 @@ public function register(): void $this->app->register(provider: EventServiceProvider::class); $this->app->singleton(abstract: 'leaderboard', concrete: fn (): LeaderboardService => new LeaderboardService()); } + + protected function registerEntityKeyMacros(): void + { + if (! Blueprint::hasMacro('entityId')) { + Blueprint::macro('entityId', function (string $column = 'id'): void { + /** @var Blueprint $this */ + match (config('level-up.entities.id_type', 'bigint')) { + 'bigint' => $this->id($column), + 'uuid' => $this->uuid($column)->primary(), + 'ulid' => $this->ulid($column)->primary(), + default => throw new InvalidArgumentException( + 'Unknown level-up.entities.id_type ['.config('level-up.entities.id_type')."]. Expected 'bigint', 'uuid', or 'ulid'." + ), + }; + }); + } + + if (! Blueprint::hasMacro('entityForeignId')) { + Blueprint::macro('entityForeignId', function (string $column): ForeignIdColumnDefinition { + /** @var Blueprint $this */ + return match (config('level-up.entities.id_type', 'bigint')) { + 'bigint' => $this->foreignId($column), + 'uuid' => $this->foreignUuid($column), + 'ulid' => $this->foreignUlid($column), + default => throw new InvalidArgumentException( + 'Unknown level-up.entities.id_type ['.config('level-up.entities.id_type')."]. Expected 'bigint', 'uuid', or 'ulid'." + ), + }; + }); + } + } } diff --git a/src/Models/Experience.php b/src/Models/Experience.php index 6dab0ba..a4ff7dd 100755 --- a/src/Models/Experience.php +++ b/src/Models/Experience.php @@ -6,9 +6,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use LevelUp\Experience\Concerns\HasConfigurableIds; class Experience extends Model { + use HasConfigurableIds; + protected $guarded = []; public function __construct(array $attributes = []) From 31e697ae47cfe06ef62fe34f3eefdba5ae8d7bb5 Mon Sep 17 00:00:00 2001 From: Chris Mellor Date: Sat, 16 May 2026 22:14:01 +0100 Subject: [PATCH 2/6] feat(entities): roll out HasConfigurableIds + entity-* macros across package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the (b) half of UUID/ULID support after the scaffold (9471843). Trait rollout: applies `HasConfigurableIds` to the remaining 11 package models so every entity honours `level-up.entities.id_type` at runtime — Achievement, Activity, Challenge, ExperienceAudit, Level, Multiplier, MultiplierScope, Streak, StreakHistory, Tier, plus the two pivot models. Macro rollout: swaps `\$table->id()` -> `entityId()` and FKs to package entities -> `entityForeignId()` across 16 migration stubs. User FKs stay as `foreignId()` (host-driven, governed by the (a) PR). Polymorphic morph fix: `multiplier_scopes.scopeable_id` becomes a string column so it can store both package-entity keys and host-User keys under any combination of the two ID-type knobs. Three sites in `Multiplier.php` cast `(string) \$key` for Postgres strict-mode correctness. BC: with default `bigint`, byte-identical to today — trait's `uniqueIds()` returns `[]` so the HasUniqueIds boot hook no-ops, `getKeyType()` returns 'int', `getIncrementing()` returns true. The scopeable_id string column accepts integers under SQLite type affinity. Tests: 216/216 pass on default config. UUID/ULID matrix follow-up. --- .../add_level_relationship_to_users_table.php.stub | 2 +- .../add_tier_id_to_achievements_table.php.stub | 2 +- .../migrations/add_tier_id_to_experiences_table.php.stub | 2 +- .../create_achievement_user_pivot_table.php.stub | 4 ++-- database/migrations/create_achievements_table.php.stub | 2 +- database/migrations/create_challenge_user_table.php.stub | 4 ++-- database/migrations/create_challenges_table.php.stub | 2 +- .../migrations/create_experience_audits_table.php.stub | 2 +- database/migrations/create_levels_table.php.stub | 2 +- .../migrations/create_multiplier_scopes_table.php.stub | 6 +++--- database/migrations/create_multipliers_table.php.stub | 2 +- .../migrations/create_streak_activities_table.php.stub | 2 +- .../migrations/create_streak_histories_table.php.stub | 4 ++-- database/migrations/create_streaks_table.php.stub | 4 ++-- database/migrations/create_tiers_table.php.stub | 2 +- .../remove_level_id_column_from_users_table.php.stub | 2 +- src/Models/Achievement.php | 3 ++- src/Models/Activity.php | 3 ++- src/Models/Challenge.php | 3 ++- src/Models/ExperienceAudit.php | 3 +++ src/Models/Level.php | 3 +++ src/Models/Multiplier.php | 9 ++++++--- src/Models/MultiplierScope.php | 3 +++ src/Models/Pivots/AchievementUser.php | 6 +++++- src/Models/Pivots/ChallengeUser.php | 3 +++ src/Models/Streak.php | 3 ++- src/Models/StreakHistory.php | 3 +++ src/Models/Tier.php | 3 ++- tests/Models/MultiplierTest.php | 2 +- 29 files changed, 59 insertions(+), 32 deletions(-) diff --git a/database/migrations/add_level_relationship_to_users_table.php.stub b/database/migrations/add_level_relationship_to_users_table.php.stub index 4424e69..15429a1 100644 --- a/database/migrations/add_level_relationship_to_users_table.php.stub +++ b/database/migrations/add_level_relationship_to_users_table.php.stub @@ -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(); diff --git a/database/migrations/add_tier_id_to_achievements_table.php.stub b/database/migrations/add_tier_id_to_achievements_table.php.stub index be7f316..09dd83e 100644 --- a/database/migrations/add_tier_id_to_achievements_table.php.stub +++ b/database/migrations/add_tier_id_to_achievements_table.php.stub @@ -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(); }); } diff --git a/database/migrations/add_tier_id_to_experiences_table.php.stub b/database/migrations/add_tier_id_to_experiences_table.php.stub index 21921b0..69e07a3 100644 --- a/database/migrations/add_tier_id_to_experiences_table.php.stub +++ b/database/migrations/add_tier_id_to_experiences_table.php.stub @@ -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(); }); } diff --git a/database/migrations/create_achievement_user_pivot_table.php.stub b/database/migrations/create_achievement_user_pivot_table.php.stub index 719e5f2..66004a2 100644 --- a/database/migrations/create_achievement_user_pivot_table.php.stub +++ b/database/migrations/create_achievement_user_pivot_table.php.stub @@ -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(); }); diff --git a/database/migrations/create_achievements_table.php.stub b/database/migrations/create_achievements_table.php.stub index db12db3..4c8f9ad 100644 --- a/database/migrations/create_achievements_table.php.stub +++ b/database/migrations/create_achievements_table.php.stub @@ -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(); diff --git a/database/migrations/create_challenge_user_table.php.stub b/database/migrations/create_challenge_user_table.php.stub index 2238fd6..0007819 100644 --- a/database/migrations/create_challenge_user_table.php.stub +++ b/database/migrations/create_challenge_user_table.php.stub @@ -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(); diff --git a/database/migrations/create_challenges_table.php.stub b/database/migrations/create_challenges_table.php.stub index 3060b90..414924e 100644 --- a/database/migrations/create_challenges_table.php.stub +++ b/database/migrations/create_challenges_table.php.stub @@ -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(); diff --git a/database/migrations/create_experience_audits_table.php.stub b/database/migrations/create_experience_audits_table.php.stub index f96a3b1..d7ab9bb 100644 --- a/database/migrations/create_experience_audits_table.php.stub +++ b/database/migrations/create_experience_audits_table.php.stub @@ -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); diff --git a/database/migrations/create_levels_table.php.stub b/database/migrations/create_levels_table.php.stub index 905f282..ab19a65 100644 --- a/database/migrations/create_levels_table.php.stub +++ b/database/migrations/create_levels_table.php.stub @@ -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(); diff --git a/database/migrations/create_multiplier_scopes_table.php.stub b/database/migrations/create_multiplier_scopes_table.php.stub index 0fc086b..39de1e6 100644 --- a/database/migrations/create_multiplier_scopes_table.php.stub +++ b/database/migrations/create_multiplier_scopes_table.php.stub @@ -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'); diff --git a/database/migrations/create_multipliers_table.php.stub b/database/migrations/create_multipliers_table.php.stub index f4e60d3..2729316 100644 --- a/database/migrations/create_multipliers_table.php.stub +++ b/database/migrations/create_multipliers_table.php.stub @@ -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); diff --git a/database/migrations/create_streak_activities_table.php.stub b/database/migrations/create_streak_activities_table.php.stub index 04f1360..7ca4e6f 100644 --- a/database/migrations/create_streak_activities_table.php.stub +++ b/database/migrations/create_streak_activities_table.php.stub @@ -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(); diff --git a/database/migrations/create_streak_histories_table.php.stub b/database/migrations/create_streak_histories_table.php.stub index 466be05..3ee3675 100644 --- a/database/migrations/create_streak_histories_table.php.stub +++ b/database/migrations/create_streak_histories_table.php.stub @@ -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(); diff --git a/database/migrations/create_streaks_table.php.stub b/database/migrations/create_streaks_table.php.stub index 6a5e42a..62060aa 100644 --- a/database/migrations/create_streaks_table.php.stub +++ b/database/migrations/create_streaks_table.php.stub @@ -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(); diff --git a/database/migrations/create_tiers_table.php.stub b/database/migrations/create_tiers_table.php.stub index 0b167ea..187b2d8 100644 --- a/database/migrations/create_tiers_table.php.stub +++ b/database/migrations/create_tiers_table.php.stub @@ -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(); diff --git a/database/migrations/remove_level_id_column_from_users_table.php.stub b/database/migrations/remove_level_id_column_from_users_table.php.stub index ab15f2a..d03ec04 100644 --- a/database/migrations/remove_level_id_column_from_users_table.php.stub +++ b/database/migrations/remove_level_id_column_from_users_table.php.stub @@ -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(); }); } }; diff --git a/src/Models/Achievement.php b/src/Models/Achievement.php index b9f80b9..ee72a30 100644 --- a/src/Models/Achievement.php +++ b/src/Models/Achievement.php @@ -8,10 +8,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use LevelUp\Experience\Concerns\HasConfigurableIds; class Achievement extends Model { - use HasFactory; + use HasConfigurableIds, HasFactory; protected $guarded = []; diff --git a/src/Models/Activity.php b/src/Models/Activity.php index 01b6e73..9957810 100644 --- a/src/Models/Activity.php +++ b/src/Models/Activity.php @@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use LevelUp\Experience\Concerns\HasConfigurableIds; class Activity extends Model { - use HasFactory; + use HasConfigurableIds, HasFactory; protected $table = 'streak_activities'; diff --git a/src/Models/Challenge.php b/src/Models/Challenge.php index e314ce5..a0aaa73 100644 --- a/src/Models/Challenge.php +++ b/src/Models/Challenge.php @@ -10,11 +10,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use InvalidArgumentException; +use LevelUp\Experience\Concerns\HasConfigurableIds; use LevelUp\Experience\Contracts\ChallengeCondition; class Challenge extends Model { - use HasFactory; + use HasConfigurableIds, HasFactory; protected $guarded = []; diff --git a/src/Models/ExperienceAudit.php b/src/Models/ExperienceAudit.php index ea0859f..0151366 100644 --- a/src/Models/ExperienceAudit.php +++ b/src/Models/ExperienceAudit.php @@ -6,10 +6,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use LevelUp\Experience\Concerns\HasConfigurableIds; use LevelUp\Experience\Enums\AuditType; class ExperienceAudit extends Model { + use HasConfigurableIds; + protected $guarded = []; protected $casts = [ diff --git a/src/Models/Level.php b/src/Models/Level.php index eaff559..6b47798 100644 --- a/src/Models/Level.php +++ b/src/Models/Level.php @@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\UniqueConstraintViolationException; +use LevelUp\Experience\Concerns\HasConfigurableIds; use LevelUp\Experience\Exceptions\LevelExistsException; class Level extends Model { + use HasConfigurableIds; + protected $guarded = []; /** diff --git a/src/Models/Multiplier.php b/src/Models/Multiplier.php index 214eba4..863a08f 100644 --- a/src/Models/Multiplier.php +++ b/src/Models/Multiplier.php @@ -10,9 +10,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphToMany; use InvalidArgumentException; +use LevelUp\Experience\Concerns\HasConfigurableIds; class Multiplier extends Model { + use HasConfigurableIds; + protected $guarded = []; protected $casts = [ @@ -42,7 +45,7 @@ public function scopeTo(Model ...$models): static foreach ($models as $model) { $this->scopes()->firstOrCreate([ 'scopeable_type' => $model->getMorphClass(), - 'scopeable_id' => $model->getKey(), + 'scopeable_id' => (string) $model->getKey(), ]); } @@ -95,12 +98,12 @@ protected function forUser(Builder $query, Model $user): void ->where(fn (Builder $match) => $match ->where([ 'scopeable_type' => $user->getMorphClass(), - 'scopeable_id' => $user->getKey(), + 'scopeable_id' => (string) $user->getKey(), ]) ->when($tierId, fn (Builder $tierMatch) => $tierMatch ->orWhere([ 'scopeable_type' => (new $tierClass)->getMorphClass(), - 'scopeable_id' => $tierId, + 'scopeable_id' => (string) $tierId, ]) ) ) diff --git a/src/Models/MultiplierScope.php b/src/Models/MultiplierScope.php index c7e02b2..1c646c2 100644 --- a/src/Models/MultiplierScope.php +++ b/src/Models/MultiplierScope.php @@ -7,9 +7,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; +use LevelUp\Experience\Concerns\HasConfigurableIds; class MultiplierScope extends Model { + use HasConfigurableIds; + protected $guarded = []; public function multiplier(): BelongsTo diff --git a/src/Models/Pivots/AchievementUser.php b/src/Models/Pivots/AchievementUser.php index 526bb46..4b29ad1 100644 --- a/src/Models/Pivots/AchievementUser.php +++ b/src/Models/Pivots/AchievementUser.php @@ -5,5 +5,9 @@ namespace LevelUp\Experience\Models\Pivots; use Illuminate\Database\Eloquent\Relations\Pivot; +use LevelUp\Experience\Concerns\HasConfigurableIds; -class AchievementUser extends Pivot {} +class AchievementUser extends Pivot +{ + use HasConfigurableIds; +} diff --git a/src/Models/Pivots/ChallengeUser.php b/src/Models/Pivots/ChallengeUser.php index 8923112..bafea0f 100644 --- a/src/Models/Pivots/ChallengeUser.php +++ b/src/Models/Pivots/ChallengeUser.php @@ -5,9 +5,12 @@ namespace LevelUp\Experience\Models\Pivots; use Illuminate\Database\Eloquent\Relations\Pivot; +use LevelUp\Experience\Concerns\HasConfigurableIds; class ChallengeUser extends Pivot { + use HasConfigurableIds; + protected $casts = [ 'completed_at' => 'datetime', 'progress' => 'array', diff --git a/src/Models/Streak.php b/src/Models/Streak.php index e4a1b94..0c7a170 100644 --- a/src/Models/Streak.php +++ b/src/Models/Streak.php @@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use LevelUp\Experience\Concerns\HasConfigurableIds; class Streak extends Model { - use HasFactory; + use HasConfigurableIds, HasFactory; protected $guarded = []; diff --git a/src/Models/StreakHistory.php b/src/Models/StreakHistory.php index f60202e..6aa572c 100644 --- a/src/Models/StreakHistory.php +++ b/src/Models/StreakHistory.php @@ -5,9 +5,12 @@ namespace LevelUp\Experience\Models; use Illuminate\Database\Eloquent\Model; +use LevelUp\Experience\Concerns\HasConfigurableIds; class StreakHistory extends Model { + use HasConfigurableIds; + protected $guarded = []; protected $casts = [ diff --git a/src/Models/Tier.php b/src/Models/Tier.php index b68bf89..8f4999c 100644 --- a/src/Models/Tier.php +++ b/src/Models/Tier.php @@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; +use LevelUp\Experience\Concerns\HasConfigurableIds; use LevelUp\Experience\Exceptions\TierExistsException; class Tier extends Model { - use HasFactory; + use HasConfigurableIds, HasFactory; protected $guarded = []; diff --git a/tests/Models/MultiplierTest.php b/tests/Models/MultiplierTest.php index 8f3a122..5d7d560 100644 --- a/tests/Models/MultiplierTest.php +++ b/tests/Models/MultiplierTest.php @@ -166,7 +166,7 @@ expect($multiplier->scopes)->toHaveCount(1) ->and($multiplier->scopes->first()->scopeable_type)->toBe(User::class) - ->and($multiplier->scopes->first()->scopeable_id)->toBe($this->user->id); + ->and($multiplier->scopes->first()->scopeable_id)->toBe((string) $this->user->id); }); test(description: 'scopeTo is idempotent for the same model', closure: function (): void { From a9639f769fc2c24283ec0f244164014ebbd6f83e Mon Sep 17 00:00:00 2001 From: Chris Mellor Date: Sat, 16 May 2026 22:24:33 +0100 Subject: [PATCH 3/6] fix(entities): activate HasUniqueIds via initialize hook in trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HasUniqueIds` only generates IDs when its `$usesUniqueIds` flag is true; the flag must be flipped per-instance via an `initialize*` trait hook (this is how `HasUuids`/`HasUlids` do it). Without this, `Model::save()` skips the `setUniqueIds()` call so package entities in uuid/ulid mode insert with a NULL primary key and trip a NOT NULL constraint. Latent in the scaffold commit (9471843) — undetected because the default bigint mode doesn't need the flag set. Surfaced once uuid/ulid mode was actually run via LEVELUP_TEST_KEY_TYPE. --- src/Concerns/HasConfigurableIds.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Concerns/HasConfigurableIds.php b/src/Concerns/HasConfigurableIds.php index 16fba70..af911e1 100644 --- a/src/Concerns/HasConfigurableIds.php +++ b/src/Concerns/HasConfigurableIds.php @@ -11,6 +11,11 @@ trait HasConfigurableIds { use HasUniqueIds; + public function initializeHasConfigurableIds(): void + { + $this->usesUniqueIds = $this->packageIdType() !== 'bigint'; + } + public function getKeyType(): string { return $this->packageIdType() === 'bigint' ? 'int' : 'string'; From c9adb11a4cf3a81b4f95f85c6f4d081cc04b12bd Mon Sep 17 00:00:00 2001 From: Chris Mellor Date: Sat, 16 May 2026 22:24:46 +0100 Subject: [PATCH 4/6] test(entities): run suite under bigint/uuid/ulid via env var + CI matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/TestCase.php` reads `LEVELUP_TEST_KEY_TYPE` (default `bigint`) and writes it to `level-up.entities.id_type` config before package migrations run. This lets the same 216-test suite exercise all three ID-type modes from a single invocation. `tests/Pest.php` captures the seeded levels into `\$this->levels` (keyed by level number) so per-test assertions can resolve a real level id instead of hardcoding `1`/`2`/`3` — which only matched the bigint auto-increment sequence. Test files that compared `level_id` to integer literals now reference `\$this->levels[N]->id`. CI: adds two extra cells to the existing matrix on PHP 8.3 + Laravel 12.* + prefer-stable, one for `uuid` and one for `ulid`. The base matrix continues to run `bigint` across PHP 8.3/8.4/8.5 + Laravel 12/13 unchanged. Verified locally: 216/216 pass under each of bigint, uuid, ulid. --- .github/workflows/run-tests.yml | 15 ++++++++++++++- tests/Concerns/GiveExperienceTest.php | 17 ++++++++--------- tests/Listeners/PointsIncreasedListenerTest.php | 10 +++++----- tests/Listeners/UserLevelledUpListenerTest.php | 2 +- tests/Pest.php | 4 +++- tests/TestCase.php | 1 + 6 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4434be8..5157b09 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -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 diff --git a/tests/Concerns/GiveExperienceTest.php b/tests/Concerns/GiveExperienceTest.php index 268a584..b65ab71 100644 --- a/tests/Concerns/GiveExperienceTest.php +++ b/tests/Concerns/GiveExperienceTest.php @@ -26,7 +26,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, 'experience_points' => 10, ]); }); @@ -45,7 +45,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, 'experience_points' => 20, ]); }); @@ -53,7 +53,7 @@ test(description: 'levels are associated on point increments', closure: function (): void { $this->user->addPoints(amount: 10); - expect($this->user->experience)->level_id->toBe(expected: 1); + expect($this->user->experience->level_id)->toBe($this->levels[1]->id); $this->user->addPoints(amount: 100); @@ -75,7 +75,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, 'experience_points' => 5, ]); }); @@ -91,7 +91,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, 'experience_points' => 5, ]); }); @@ -117,7 +117,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, 'experience_points' => 20, ]); }); @@ -139,7 +139,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, 'experience_points' => 20, ]); }); @@ -420,7 +420,6 @@ expect(Level::query()->count())->toBe(expected: 1); $this->assertDatabaseHas(table: 'levels', data: [ - 'id' => 1, 'level' => 1, 'next_level_experience' => null, ]); @@ -432,7 +431,7 @@ test(description: 'the level is correct when adding more points than available on initial experience gain', closure: function (): void { $this->user->addPoints(amount: 10); - expect($this->user->experience)->level_id->toBe(expected: 1); + expect($this->user->experience->level_id)->toBe($this->levels[1]->id); $this->user->setPoints(amount: 0); $this->user->addPoints(100); diff --git a/tests/Listeners/PointsIncreasedListenerTest.php b/tests/Listeners/PointsIncreasedListenerTest.php index 972ad57..cdf077b 100644 --- a/tests/Listeners/PointsIncreasedListenerTest.php +++ b/tests/Listeners/PointsIncreasedListenerTest.php @@ -26,11 +26,11 @@ test(description: 'the level_id in Experience table defaults to 1 on Model creation', closure: function (): void { $this->user->addPoints(1); - expect($this->user->experience->level_id)->toBe(expected: 1); + expect($this->user->experience->level_id)->toBe($this->levels[1]->id); $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 1, + 'level_id' => $this->levels[1]->id, ]); }); @@ -74,14 +74,14 @@ $this->user->addPoints(amount: 100); expect($this->user->getLevel())->toBe(expected: 2) - ->and($this->user->experience->level_id)->toBe(expected: 2) + ->and($this->user->experience->level_id)->toBe($this->levels[2]->id) ->and($this->user->nextLevelAt())->toBe(expected: 150) ->and($this->user->experience->status->level)->toBe(expected: 2); $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, 'experience_points' => 100, - 'level_id' => 2, + 'level_id' => $this->levels[2]->id, ]); $this->user->addPoints(amount: 150); @@ -92,7 +92,7 @@ $this->assertDatabaseHas(table: 'experiences', data: [ 'user_id' => $this->user->id, - 'level_id' => 3, + 'level_id' => $this->levels[3]->id, ]); }); diff --git a/tests/Listeners/UserLevelledUpListenerTest.php b/tests/Listeners/UserLevelledUpListenerTest.php index e71fe92..a5a4004 100644 --- a/tests/Listeners/UserLevelledUpListenerTest.php +++ b/tests/Listeners/UserLevelledUpListenerTest.php @@ -39,7 +39,7 @@ $this->user->addPoints(300); - expect($this->user)->experience->level_id->toBe(expected: 3); + expect($this->user)->experience->level_id->toBe($this->levels[3]->id); Event::assertDispatchedTimes(event: UserLevelledUp::class, times: 3); diff --git a/tests/Pest.php b/tests/Pest.php index 78cb0cd..dc796e5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -18,13 +18,15 @@ 'email_verified_at' => now(), ])->save(); - Level::add( + $levels = 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' => 400], ['level' => 5, 'next_level_experience' => 600], ); + + $this->levels = collect($levels)->keyBy('level'); }) ->in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 3704fb3..583e727 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -34,6 +34,7 @@ protected function getEnvironmentSetUp($app): void { config()->set('database.default', 'testing'); config()->set('level-up.user.model', \LevelUp\Experience\Tests\Fixtures\User::class); + config()->set('level-up.entities.id_type', env('LEVELUP_TEST_KEY_TYPE', 'bigint')); Schema::create('users', function ($table): void { $table->id(); From 802826f012a5bd10af0ec65dd6c9842402f721f5 Mon Sep 17 00:00:00 2001 From: Chris Mellor Date: Sat, 16 May 2026 22:28:12 +0100 Subject: [PATCH 5/6] docs(entities): document configurable entity ID type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: adds a "Customizing Identifiers" section explaining `level-up.entities.id_type` and a collapsible accordion with an AI prompt that users can paste into their assistant to generate the conversion migrations for switching an existing install from bigint to uuid/ulid. Existing-install conversion is intentionally not automated by the package — it depends on data volume, downtime tolerance, and DB driver specifics. The published-config snippet in the README also gains the new `entities` block so users see the knob immediately on install. UPGRADE.md: adds a brief "New: Configurable Entity ID Type" entry under v2.0 pointing at the README section. Existing installs are unaffected on `composer update` since the default is `bigint`. v2 upgrade skill (resources/boost/skills/level-up-upgrade-v2): inserts Step 9 that asks whether the user wants to switch IDs and explicitly delegates the conversion to the README accordion rather than attempting it inside the skill. CHANGELOG: left untouched — handled at release time. --- README.md | 57 +++++++++++++++++++ UPGRADE.md | 6 ++ .../boost/skills/level-up-upgrade-v2/SKILL.md | 16 +++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a57da01..2fd044d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. + +
+AI prompt: convert an existing install to uuid or ulid + +Paste this into your AI assistant. Replace `` with `uuid` or `ulid` and `` with `postgres`, `mysql`, or `sqlite`. + +```text +I'm switching cjmellor/level-up's `entities.id_type` from `bigint` to `` on an existing `` 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 `` column called `id_new`, generate a unique value for every existing row, then later drop the old `id` and rename `id_new` to `id`. + +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 `` 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 ``. +``` + +Review the generated migration carefully against your schema and data volume before running it on production. + +
+ # Testing ``` diff --git a/UPGRADE.md b/UPGRADE.md index a26772f..56da481 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -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) diff --git a/resources/boost/skills/level-up-upgrade-v2/SKILL.md b/resources/boost/skills/level-up-upgrade-v2/SKILL.md index 4254736..a12f6c5 100644 --- a/resources/boost/skills/level-up-upgrade-v2/SKILL.md +++ b/resources/boost/skills/level-up-upgrade-v2/SKILL.md @@ -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." From bdb1838b40e25b6fdd8d83d9ff6f94dbb1ddebfc Mon Sep 17 00:00:00 2001 From: Chris Mellor Date: Sun, 17 May 2026 20:48:47 +0100 Subject: [PATCH 6/6] refactor(entities): apply audit polish to configurable-id-type rollout - Tighten HasConfigurableIds: only flip usesUniqueIds for uuid/ulid; newUniqueId() throws InvalidArgumentException on unknown types instead of silently returning an empty string. - Extract $resolveIdType closure in LevelUpServiceProvider so both Blueprint macros share a single validate-and-throw site. - Cast (string) on scopeable_id test inserts for parity with production casts (Postgres-strict safe). - Inline the multiplier_scopes clarifier next to the table list in the README AI prompt. --- README.md | 2 +- src/Concerns/HasConfigurableIds.php | 11 ++++++++--- src/LevelUpServiceProvider.php | 25 +++++++++++++++---------- tests/Models/MultiplierTest.php | 16 ++++++++-------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2fd044d..2c41e62 100644 --- a/README.md +++ b/README.md @@ -1000,7 +1000,7 @@ I'm switching cjmellor/level-up's `entities.id_type` from `bigint` to `` 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 `` column called `id_new`, generate a unique value for every existing row, then later drop the old `id` and rename `id_new` to `id`. +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 `` 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. diff --git a/src/Concerns/HasConfigurableIds.php b/src/Concerns/HasConfigurableIds.php index af911e1..e64fede 100644 --- a/src/Concerns/HasConfigurableIds.php +++ b/src/Concerns/HasConfigurableIds.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUniqueIds; use Illuminate\Support\Str; +use InvalidArgumentException; trait HasConfigurableIds { @@ -13,7 +14,7 @@ trait HasConfigurableIds public function initializeHasConfigurableIds(): void { - $this->usesUniqueIds = $this->packageIdType() !== 'bigint'; + $this->usesUniqueIds = in_array($this->packageIdType(), ['uuid', 'ulid'], true); } public function getKeyType(): string @@ -28,10 +29,14 @@ public function getIncrementing(): bool public function newUniqueId(): string { - return match ($this->packageIdType()) { + $type = $this->packageIdType(); + + return match ($type) { 'ulid' => strtolower((string) Str::ulid()), 'uuid' => (string) Str::uuid(), - default => '', + default => throw new InvalidArgumentException( + "Unknown level-up.entities.id_type [{$type}]. Expected 'bigint', 'uuid', or 'ulid'." + ), }; } diff --git a/src/LevelUpServiceProvider.php b/src/LevelUpServiceProvider.php index 108d760..fdaa0db 100644 --- a/src/LevelUpServiceProvider.php +++ b/src/LevelUpServiceProvider.php @@ -58,30 +58,35 @@ public function register(): void protected function registerEntityKeyMacros(): void { + $resolveIdType = static function (): string { + $idType = config('level-up.entities.id_type', 'bigint'); + + return match ($idType) { + 'bigint', 'uuid', 'ulid' => $idType, + default => throw new InvalidArgumentException( + "Unknown level-up.entities.id_type [{$idType}]. Expected 'bigint', 'uuid', or 'ulid'." + ), + }; + }; + if (! Blueprint::hasMacro('entityId')) { - Blueprint::macro('entityId', function (string $column = 'id'): void { + Blueprint::macro('entityId', function (string $column = 'id') use ($resolveIdType): void { /** @var Blueprint $this */ - match (config('level-up.entities.id_type', 'bigint')) { + match ($resolveIdType()) { 'bigint' => $this->id($column), 'uuid' => $this->uuid($column)->primary(), 'ulid' => $this->ulid($column)->primary(), - default => throw new InvalidArgumentException( - 'Unknown level-up.entities.id_type ['.config('level-up.entities.id_type')."]. Expected 'bigint', 'uuid', or 'ulid'." - ), }; }); } if (! Blueprint::hasMacro('entityForeignId')) { - Blueprint::macro('entityForeignId', function (string $column): ForeignIdColumnDefinition { + Blueprint::macro('entityForeignId', function (string $column) use ($resolveIdType): ForeignIdColumnDefinition { /** @var Blueprint $this */ - return match (config('level-up.entities.id_type', 'bigint')) { + return match ($resolveIdType()) { 'bigint' => $this->foreignId($column), 'uuid' => $this->foreignUuid($column), 'ulid' => $this->foreignUlid($column), - default => throw new InvalidArgumentException( - 'Unknown level-up.entities.id_type ['.config('level-up.entities.id_type')."]. Expected 'bigint', 'uuid', or 'ulid'." - ), }; }); } diff --git a/tests/Models/MultiplierTest.php b/tests/Models/MultiplierTest.php index 5d7d560..394c738 100644 --- a/tests/Models/MultiplierTest.php +++ b/tests/Models/MultiplierTest.php @@ -70,7 +70,7 @@ $multiplier->scopes()->create([ 'scopeable_type' => User::class, - 'scopeable_id' => $this->user->id, + 'scopeable_id' => (string) $this->user->id, ]); $otherUser = User::query()->create(['name' => 'Other', 'email' => 'other@test.com', 'password' => bcrypt(value: 'password')]); @@ -90,7 +90,7 @@ $multiplier = Multiplier::query()->create(['name' => 'Silver Bonus', 'multiplier' => 2, 'is_active' => true]); $multiplier->scopes()->create([ 'scopeable_type' => Tier::class, - 'scopeable_id' => $silverTier->id, + 'scopeable_id' => (string) $silverTier->id, ]); $this->user->addPoints(amount: 10); @@ -124,8 +124,8 @@ $multiplier = Multiplier::query()->create(['name' => 'Scoped', 'multiplier' => 2, 'is_active' => true]); $multiplier->scopes()->createMany([ - ['scopeable_type' => User::class, 'scopeable_id' => 1], - ['scopeable_type' => Tier::class, 'scopeable_id' => 1], + ['scopeable_type' => User::class, 'scopeable_id' => '1'], + ['scopeable_type' => Tier::class, 'scopeable_id' => '1'], ]); expect($multiplier->scopes)->toHaveCount(2); @@ -133,7 +133,7 @@ test(description: 'multiplier scopes are associated correctly', closure: function (): void { $multiplier = Multiplier::query()->create(['name' => 'Scoped', 'multiplier' => 2, 'is_active' => true]); - $multiplier->scopes()->create(['scopeable_type' => User::class, 'scopeable_id' => 1]); + $multiplier->scopes()->create(['scopeable_type' => User::class, 'scopeable_id' => '1']); expect(MultiplierScope::query()->count())->toBe(1) ->and(MultiplierScope::query()->first()->multiplier_id)->toBe($multiplier->id); @@ -186,7 +186,7 @@ $multiplier = Multiplier::query()->create(['name' => 'Dual Scoped', 'multiplier' => 3, 'is_active' => true]); $multiplier->scopeTo($this->user); - $multiplier->scopes()->create(['scopeable_type' => Tier::class, 'scopeable_id' => $tier->id]); + $multiplier->scopes()->create(['scopeable_type' => Tier::class, 'scopeable_id' => (string) $tier->id]); $this->user->addPoints(amount: 10); $this->user->experience->update(['tier_id' => $tier->id]); @@ -200,7 +200,7 @@ test(description: 'tiers relationship returns morphed tiers', closure: function (): void { $tier = Tier::query()->create(['name' => 'Gold', 'experience' => 500]); $multiplier = Multiplier::query()->create(['name' => 'Tier Bonus', 'multiplier' => 3, 'is_active' => true]); - $multiplier->scopes()->create(['scopeable_type' => Tier::class, 'scopeable_id' => $tier->id]); + $multiplier->scopes()->create(['scopeable_type' => Tier::class, 'scopeable_id' => (string) $tier->id]); expect($multiplier->tiers)->toHaveCount(1) ->and($multiplier->tiers->first()->name)->toBe('Gold'); @@ -210,7 +210,7 @@ config()->set('level-up.user.model', User::class); $multiplier = Multiplier::query()->create(['name' => 'User Bonus', 'multiplier' => 2, 'is_active' => true]); - $multiplier->scopes()->create(['scopeable_type' => User::class, 'scopeable_id' => $this->user->id]); + $multiplier->scopes()->create(['scopeable_type' => User::class, 'scopeable_id' => (string) $this->user->id]); expect($multiplier->users)->toHaveCount(1) ->and($multiplier->users->first()->id)->toBe($this->user->id);