Tilly The Coder
Tilly The Coder
Filament v4: 3 Clean Ways to Generate a Slug from a Title (No Packages)
Tilly The Coder

Filament v4: 3 Clean Ways to Generate a Slug from a Title (No Packages)

Tilly The Coder
5 mins
This is the estimated time it takes to read the article.

Filament v4 shipped as a stable release on Aug 12, 2025, and the slug recipe in the v4 docs is still the right starting point: keep the title field reactive, use afterStateUpdated() to set the slug, and let Laravel's Str::slug() handle the string normalization. The Search Console crawl date from Apr 27, 2026 is used here as the publish date.

The goal is not to add a slug package. In a normal Laravel admin panel you already have the pieces you need: a title input, a slug input, validation, and a server-side guarantee that saved records cannot drift into invalid URLs.

A good slug workflow has three jobs. It should help the editor move quickly, it should respect manual edits, and it should protect the database when content is imported, seeded, or updated outside the Filament screen. If one of those jobs is missing, the panel will feel fine until the first renamed article or duplicated title creates a broken link.

1. Generate on blur in the Filament form

The clean default is to update the slug when the title field loses focus. That avoids a network request on every keystroke while still giving editors quick feedback.

use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Str;

TextInput::make('title')
    ->required()
    ->live(onBlur: true)
    ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state): void {
        if (($get('slug') ?? '') !== Str::slug($old ?? '')) {
            return;
        }

        $set('slug', Str::slug($state ?? ''));
    });

TextInput::make('slug')
    ->required()
    ->unique(ignoreRecord: true);

The important detail is the manual-edit guard. If the editor changes the slug by hand, a later title edit should not overwrite that decision.

This is the option I use most often. Editors get a suggested slug, the Livewire request happens at a natural pause, and the logic still uses Laravel string handling. It also plays nicely with validation because the slug field is a normal server-backed field.

2. Use JavaScript for a no-request mirror

Filament v4 added more tools for reducing network requests. If the only thing you need is a quick visual mirror from title to slug, afterStateUpdatedJs() can update the frontend state without round-tripping to Livewire.

TextInput::make('title')
    ->afterStateUpdatedJs(<<<'JS'
        $set(
            'slug',
            ($state ?? '')
                .toString()
                .normalize('NFD')
                .replace(/[\u0300-\u036f]/g, '')
                .replace(/[^a-zA-Z0-9\s-]/g, '')
                .trim()
                .replace(/\s+/g, '-')
                .replace(/-+/g, '-')
                .toLowerCase()
        )
    JS);

This is great for responsiveness, but do not treat it as your only rule. Browser-side helpers are user interface behavior. Your server still owns the saved data.

Client-side slugging also has a language tradeoff. The small JavaScript normalizer above is fine for simple English titles, but Laravel's Str::slug() knows how your app is configured and is easier to test. If your content has accented characters, non-Latin text, or custom transliteration rules, prefer PHP for the final value and use JavaScript only as a preview.

3. Normalize on save as the final guard

The most reliable projects keep a server-side guard even when the form feels polished. You can do this in the model, an observer, or an action that owns record creation.

protected static function booted(): void
{
    static::saving(function (Article $article): void {
        if (blank($article->slug)) {
            $article->slug = Str::slug($article->title);
        }

        $article->slug = Str::slug($article->slug);
    });
}

If your slugs must be globally unique, keep the database unique index and form validation. If you need automatic suffixes such as my-post-2, put that logic in a dedicated action so it can be tested without booting a Filament page.

final class GenerateUniqueSlug
{
    public function handle(string $title, ?int $ignoreId = null): string
    {
        $base = Str::slug($title);
        $slug = $base;
        $count = 2;

        while (
            Article::query()
                ->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))
                ->where('slug', $slug)
                ->exists()
        ) {
            $slug = "{$base}-{$count}";
            $count++;
        }

        return $slug;
    }
}

That action is intentionally boring. It can be unit tested, reused by imports, and called from the Filament save flow when the product wants automatic suffixes. For a high-traffic public site, keep the unique database index as the final authority because two requests can still race each other.

Editing published slugs

The form examples above are for creation and draft editing. Published content needs a stricter rule. If an article has already been indexed, emailed, or linked from a course, changing the slug should be a deliberate editorial action, not a side effect of changing the title.

  • Do not auto-update the slug after the record has been published unless the editor explicitly asks for it.

  • Store redirects when old URLs must continue working.

  • Keep the canonical URL in sync with the final slug.

  • Show a warning in Filament when a published slug changes.

TextInput::make('slug')
    ->required()
    ->helperText(fn (?Article $record): string => $record?->published_at
        ? 'Changing this slug changes the public URL. Create a redirect if it is already shared.'
        : 'This will be used in the article URL.')
    ->unique(ignoreRecord: true);

Validation and database shape

The slug field should have a max length that matches the database column, a unique rule that ignores the current record on edit, and a real unique index. If the content is scoped to a parent, such as course lessons, make the unique rule match that scope instead of enforcing global uniqueness by accident.

TextInput::make('slug')
    ->required()
    ->maxLength(160)
    ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')
    ->unique(ignoreRecord: true);

What I normally ship

  • Form: live(onBlur: true) plus afterStateUpdated() with the manual-edit guard.

  • Validation: required, maxLength, and unique(ignoreRecord: true) on the slug field.

  • Persistence: a save-time normalization guard so imports, seeders, and API writes follow the same rule.

  • URLs: never change a published slug silently if the URL is already indexed; create redirects when you intentionally rename it.

  • Tests: one test for automatic generation, one for manual slug preservation, and one for duplicate titles.

That combination keeps the editor experience fast, the code easy to read, and the URL data safe without introducing another dependency.