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.

In Filament v4, there are multiple ergonomic ways to keep a slug field in sync with a title—purely within Filament, no third-party packages. Below are three production-ready patterns that only show the Title and Slug inputs.

Highlights of the v4 features used here:

  • afterStateUpdatedJs() – a client-side lifecycle hook that mutates other fields without a network round-trip.

  • Partial renderingpartiallyRenderComponentsAfterStateUpdated() to avoid re-rendering the whole schema when only one field needs updating.

  • Dehydration hooksdehydrateStateUsing() to compute or normalize values right before save.

  • Utility injection – type-hinted utilities like Get and Set inside callbacks.

1) Instant client-side slugging (your example, v4 JS lifecycle)

This pattern updates the slug in the browser as the user types, with a small JS slugify() helper. Because it uses afterStateUpdatedJs(), there’s no Livewire re-render while typing.

<?php

declare(strict_types=1);

namespace App\Filament\Schemas\TitleSlug;

use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;

final class JsReactiveTitleSlug
{
    public static function configure(Schema $schema): Schema
    {
        return $schema->components([
            TextInput::make('title')
                ->label('Title')
                ->required()
                ->maxLength(255)
                ->afterStateUpdatedJs(<<<'JS'
                    // v4: client-side lifecycle hook (no network request).
                    function slugify(text) {
                        return (text ?? '')
                            .toString()
                            .normalize('NFD')                 // split accents
                            .replace(/[\u0300-\u036f]/g, '') // strip diacritics
                            .replace(/[^a-zA-Z0-9\s-]/g, '') // keep word chars, spaces & dashes
                            .trim()
                            .replace(/\s+/g, '-')            // spaces -> dashes
                            .replace(/-+/g, '-')             // collapse dashes
                            .toLowerCase();
                    }
                    $set('slug', slugify($state));
                JS),

            TextInput::make('slug')
                ->label('Slug')
                ->required()
                ->maxLength(255)
                ->unique(ignoreRecord: true),
        ]);
    }
}

When to use: You want instant feedback and the simplest UX.
Nice v4 touch: afterStateUpdatedJs() gives you instant client logic with $set() and zero Livewire requests.

2) Server-side (PHP) on blur + partial rendering (don’t overwrite manual edits)

This pattern keeps the logic in PHP using Str::slug(), updates on blur (fewer network calls), and uses v4’s partial rendering so only the slug field updates. It also avoids overwriting a user-edited slug by comparing against the previous auto-generated one.

<?php

declare(strict_types=1);

namespace App\Filament\Schemas\TitleSlug;

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

final class ServerReactiveTitleSlug
{
    public static function configure(Schema $schema): Schema
    {
        return $schema->components([
            TextInput::make('title')
                ->label('Title')
                ->required()
                ->maxLength(255)
                // v4: re-render on blur only (fewer requests while typing)
                ->live(onBlur: true)
                // v4: only re-render the slug field after title updates
                ->partiallyRenderComponentsAfterStateUpdated(['slug'])
                ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state): void {
                    // If the current slug differs from the previous auto-slug of the old title,
                    // we assume the user edited it manually—do not overwrite.
                    $currentSlug = (string) ($get('slug') ?? '');
                    $autoFromOld = Str::slug((string) $old);

                    if ($currentSlug !== $autoFromOld) {
                        return;
                    }

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

            TextInput::make('slug')
                ->label('Slug')
                ->required()
                ->maxLength(255)
                ->unique(ignoreRecord: true),
        ]);
    }
}

When to use: You prefer PHP logic, want to minimize network churn, and care about not clobbering a user-modified slug.
Nice v4 touches: live(onBlur: true) + partiallyRenderComponentsAfterStateUpdated() make the interaction snappy while staying server-driven.

3) Compute the slug only on save (dehydration hook, zero reactivity)

This pattern leaves the slug alone while typing and derives it at submit time. If the user typed a slug, it’s normalized; if not, it’s generated from the title. This keeps your create/edit UI quiet and the logic centralized in one place.

<?php

declare(strict_types=1);

namespace App\Filament\Schemas\TitleSlug;

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

final class DehydratedTitleSlug
{
    public static function configure(Schema $schema): Schema
    {
        return $schema->components([
            TextInput::make('title')
                ->label('Title')
                ->required()
                ->maxLength(255),

            TextInput::make('slug')
                ->label('Slug')
                ->required()
                ->maxLength(255)
                ->unique(ignoreRecord: true)
                // v4: compute/normalize right before save
                ->dehydrateStateUsing(function (Get $get, ?string $state): string {
                    // If user typed a slug, normalize it; otherwise derive from title.
                    if (filled($state)) {
                        return Str::slug($state);
                    }

                    return Str::slug((string) $get('title'));
                }),
        ]);
    }
}

When to use: You want no reactive updates, a very calm form, and a single source of truth at submit time.
Nice v4 touch: dehydrateStateUsing() runs at the perfect moment—just before persistence—so you don’t need to juggle reactive hooks.

Which one should you pick?

  • Fastest feel / no requests while typing: go with #1 (JS).

  • All-PHP + smart overwrite protection: go with #2 (server on-blur + partial render).

  • Calmest UI + centralized “on save” logic: go with #3 (dehydration).

Bonus tips (v4 niceties you might add)

  • Lock the slug after publish: TextInput::make('slug')->disabledOn('edit');

  • If you keep it editable on edit, still normalize on save (see #3) so URLs remain consistent.

  • Keep ->unique(ignoreRecord: true) on the slug to avoid collisions.

That’s it—three small, focused patterns, each leveraging Filament v4 features in a slightly different way so you can choose the UX and runtime model that fits your resource best.