Skip to content

feat: use team slug instead of ULID in panel URLs#114

Merged
ManukMinasyan merged 1 commit into3.xfrom
feature/team-slug-urls
Feb 12, 2026
Merged

feat: use team slug instead of ULID in panel URLs#114
ManukMinasyan merged 1 commit into3.xfrom
feature/team-slug-urls

Conversation

@ManukMinasyan
Copy link
Copy Markdown
Contributor

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.

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.
Copilot AI review requested due to automatic review settings February 12, 2026 14:06
@ManukMinasyan ManukMinasyan merged commit 2bd1ff1 into 3.x Feb 12, 2026
10 of 12 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.slug with migration backfill + uniqueness constraint, and introduce TeamObserver for slug generation on create.
  • Update Filament panel tenancy to resolve tenants by slug and 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.

Comment on lines +27 to +37
$slug = $baseSlug;
$counter = 2;

while (Team::query()->where('slug', $slug)->exists()) {
$slug = "{$baseSlug}-{$counter}";
$counter++;
}

return $slug;
}
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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;
}
}
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +126
$team = Team::query()->create([
'name' => 'Acme Corp',
'user_id' => $user->id,
'personal_team' => true,
]);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +38
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 => [
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 => [

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +17
return new class extends Migration
{
public function up(): void
{
Schema::table('teams', function (Blueprint $table): void {
$table->string('slug')->nullable()->after('name');
});
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +49
$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]);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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]);
}
});

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants