feat: use team slug instead of ULID in panel URLs#114
Conversation
Add slug column to teams table with migration backfill for existing records. TeamObserver auto-generates unique slugs on creation. Filament tenant resolution now uses slug attribute. Team settings form supports editing slug with auto-generation from name, validation, and redirect on slug change.
There was a problem hiding this comment.
Pull request overview
This PR switches Filament tenancy URLs from team ULIDs to human-readable team slugs by adding a slug column, generating/backfilling slugs, and updating tenant resolution and the team settings UI to support editing and validating slugs.
Changes:
- Add
teams.slugwith migration backfill + uniqueness constraint, and introduceTeamObserverfor slug generation on create. - Update Filament panel tenancy to resolve tenants by
slugand update the team settings Livewire form to edit/validate slug and redirect on slug changes. - Expand unit/feature tests to cover slug generation and slug validation/redirect behavior.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
database/migrations/2026_02_11_232804_add_slug_to_teams_table.php |
Adds slug column, backfills existing rows, then enforces non-null + unique constraint. |
app/Observers/TeamObserver.php |
Generates a slug on creating when missing, ensuring uniqueness via DB checks. |
app/Models/Team.php |
Registers the observer, adds slug to fillable, and introduces a shared slug regex constant. |
app/Providers/Filament/AppPanelProvider.php |
Configures Filament tenancy to use slug for tenant resolution. |
app/Livewire/App/Teams/UpdateTeamName.php |
Adds slug field to the team settings form with auto-generation, validation, and redirect-on-change. |
app/Actions/Jetstream/UpdateTeamName.php |
Extends backend validation and persistence to include slug. |
lang/en/teams.php |
Adds labels/helper text for the new “Team Slug” field. |
database/factories/TeamFactory.php |
Attempts to generate slugs during factory creation. |
tests/Pest.php |
Adjusts global event faking to allow Team creating events for slug generation. |
tests/Unit/Models/TeamTest.php |
Adds unit coverage for observer slug generation edge cases. |
tests/Feature/Teams/UpdateTeamNameTest.php |
Adds feature coverage for slug validation, uniqueness, and redirect behavior. |
| $slug = $baseSlug; | ||
| $counter = 2; | ||
|
|
||
| while (Team::query()->where('slug', $slug)->exists()) { | ||
| $slug = "{$baseSlug}-{$counter}"; | ||
| $counter++; | ||
| } | ||
|
|
||
| return $slug; | ||
| } | ||
| } |
There was a problem hiding this comment.
Slug uniqueness here is checked with exists() in a loop, which is vulnerable to a race condition under concurrent team creation (two requests can pick the same slug before either commit). Since the DB unique index will still reject duplicates, consider handling the resulting integrity exception and retrying slug generation (or using a transaction + lock) to avoid a user-facing 500.
| $slug = $baseSlug; | |
| $counter = 2; | |
| while (Team::query()->where('slug', $slug)->exists()) { | |
| $slug = "{$baseSlug}-{$counter}"; | |
| $counter++; | |
| } | |
| return $slug; | |
| } | |
| } | |
| $slug = sprintf('%s-%s', $baseSlug, Str::lower(Str::random(6))); | |
| return $slug; | |
| } | |
| } | |
| } | |
| } |
| $team = Team::query()->create([ | ||
| 'name' => 'Acme Corp', | ||
| 'user_id' => $user->id, | ||
| 'personal_team' => true, | ||
| ]); |
There was a problem hiding this comment.
Team::query()->create([...]) will throw a MassAssignmentException here because App\Models\Team::$fillable doesn’t include user_id. To exercise the observer without opening up mass assignment, use forceCreate() / forceFill()->save() (or wrap the create call in Team::unguarded(...)) when setting user_id in these tests.
| return $this->afterMaking(function (Team $team): void { | ||
| if (blank($team->slug)) { | ||
| $team->slug = Str::slug($team->name) . '-' . Str::lower(Str::random(5)); | ||
| } | ||
| })->sequence(fn (Sequence $sequence): array => [ |
There was a problem hiding this comment.
The factory is generating a random-suffixed slug in afterMaking(), which prevents the TeamObserver from producing the canonical slug (e.g. acme-corp) and makes slugs in tests/seeds unexpectedly change shape. Consider removing this factory slug mutation (or moving slug generation to afterCreating() only when you explicitly want non-observer behavior).
| return $this->afterMaking(function (Team $team): void { | |
| if (blank($team->slug)) { | |
| $team->slug = Str::slug($team->name) . '-' . Str::lower(Str::random(5)); | |
| } | |
| })->sequence(fn (Sequence $sequence): array => [ | |
| return $this->sequence(fn (Sequence $sequence): array => [ |
| return new class extends Migration | ||
| { | ||
| public function up(): void | ||
| { | ||
| Schema::table('teams', function (Blueprint $table): void { | ||
| $table->string('slug')->nullable()->after('name'); | ||
| }); |
There was a problem hiding this comment.
This migration doesn’t implement a down() method. Other migrations in the repo define down() for rollback support; adding one here (drop the unique index + slug column) would keep migrations consistent and make rollbacks in dev/CI possible.
| $teams = DB::table('teams')->orderBy('created_at')->get(); | ||
| $usedSlugs = []; | ||
|
|
||
| foreach ($teams as $team) { | ||
| $baseSlug = Str::slug($team->name); | ||
|
|
||
| if ($baseSlug === '') { | ||
| $baseSlug = Str::lower(Str::random(8)); | ||
| } | ||
|
|
||
| $slug = $baseSlug; | ||
| $counter = 2; | ||
|
|
||
| while (in_array($slug, $usedSlugs, true)) { | ||
| $slug = "{$baseSlug}-{$counter}"; | ||
| $counter++; | ||
| } | ||
|
|
||
| $usedSlugs[] = $slug; | ||
|
|
||
| DB::table('teams')->where('id', $team->id)->update(['slug' => $slug]); | ||
| } |
There was a problem hiding this comment.
backfillSlugs() loads the entire teams table into memory (->get()) and keeps an in-memory $usedSlugs array, which can become slow / memory-heavy on larger datasets. Consider chunking through teams (e.g. chunkById) and using a faster lookup structure (associative array / set) for used slugs.
| $teams = DB::table('teams')->orderBy('created_at')->get(); | |
| $usedSlugs = []; | |
| foreach ($teams as $team) { | |
| $baseSlug = Str::slug($team->name); | |
| if ($baseSlug === '') { | |
| $baseSlug = Str::lower(Str::random(8)); | |
| } | |
| $slug = $baseSlug; | |
| $counter = 2; | |
| while (in_array($slug, $usedSlugs, true)) { | |
| $slug = "{$baseSlug}-{$counter}"; | |
| $counter++; | |
| } | |
| $usedSlugs[] = $slug; | |
| DB::table('teams')->where('id', $team->id)->update(['slug' => $slug]); | |
| } | |
| $usedSlugs = []; | |
| DB::table('teams') | |
| ->orderBy('id') | |
| ->chunkById(500, function ($teams) use (&$usedSlugs): void { | |
| foreach ($teams as $team) { | |
| $baseSlug = Str::slug($team->name); | |
| if ($baseSlug === '') { | |
| $baseSlug = Str::lower(Str::random(8)); | |
| } | |
| $slug = $baseSlug; | |
| $counter = 2; | |
| while (isset($usedSlugs[$slug])) { | |
| $slug = "{$baseSlug}-{$counter}"; | |
| $counter++; | |
| } | |
| $usedSlugs[$slug] = true; | |
| DB::table('teams') | |
| ->where('id', $team->id) | |
| ->update(['slug' => $slug]); | |
| } | |
| }); |
Add slug column to teams table with migration backfill for existing records. TeamObserver auto-generates unique slugs on creation. Filament tenant resolution now uses slug attribute. Team settings form supports editing slug with auto-generation from name, validation, and redirect on slug change.