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 rendering – partiallyRenderComponentsAfterStateUpdated()
to avoid re-rendering the whole schema when only one field needs updating.
Dehydration hooks – dehydrateStateUsing()
to compute or normalize values right before save.
Utility injection – type-hinted utilities like Get
and Set
inside callbacks.
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.
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.
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.
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).
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.