blendbyte/filament-title-with-slug
Filament v5 field combining title and slug with automatic slug generation, live permalink preview, and inline editing. Supports Laravel 11–13.
1. Install (one command, no build step)
composer require blendbyte/filament-title-with-slug
Nothing else is strictly required. In v3 the stylesheet is registered through Filament's asset system, so there is no Tailwind/Vite import to add (removing a legacy @import from a v1/v2 install is part of the upgrade).
2. Check the prerequisites before anything else
| Requirement | Value |
|---|---|
| Package version | ^3.0 (v1/v2 are deprecated, security fixes only) |
| Filament | ^5.0 — this will not work on Filament v4 or earlier |
| Laravel | ^11 / ^12 / ^13 |
| PHP | 8.2+ |
If your app is on Filament v4, stop here — you need blendbyte/filament-title-with-slug:^2.x (namespace Camya\Filament\Forms\Components) or an app upgrade first.
3. Make sure the data model supports it
The component binds to title and slug by default, so the table needs both columns plus a unique index:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->timestamps();
});
4. First use case — drop it into a resource form
use Blendbyte\FilamentTitleWithSlug\TitleWithSlugInput;
public static function form(Form $form): Form
{
return $form->schema([
TitleWithSlugInput::make()->columnSpan('full'),
// ... rest of the form
]);
}
That's it: you now get title input → auto slug on typing, a live permalink preview, an inline edit affordance with OK / Cancel / Reset, and a "Visit" link on the edit page.
5. Where to look first
The README's table of contents is the de-facto documentation — it is the only docs surface for this package. Skim All available parameters early; it is the fastest way to know what is configurable vs. what you'd have to override.
Publish the config and read it before hardcoding options into every resource:
php artisan vendor:publish --tag="filament-title-with-slug-config"
Project-wide defaults (urlHost, urlPath, labels, etc.) live there — set them once, override per-resource.
Publish translations if you ship a non-English admin panel:
php artisan vendor:publish --tag="filament-title-with-slug-translations"
# → lang/vendor/filament-title-with-slug
Bundled: en, fr, pt_BR, de, nl, id, ar.
The docs/examples/*.jpg screenshots in the repo are the quickest way to see the rendered states (host hidden, readonly, dark mode, repeater) before you build.
6. Verify the read path works too
The package only handles input. Your front end still needs a route that accepts the slug — model binding by slug (getRouteKeyName()), or an explicit lookup — otherwise the "Visit" link and the permalink preview are lying to editors.
Because make() is a long parameter list, wrap it in your own factory so each resource stays readable and you can swap the package later:
namespace App\Filament\Forms\Components;
use Blendbyte\FilamentTitleWithSlug\TitleWithSlugInput;
class PostTitleSlug
{
public static function make(): TitleWithSlugInput
{
return TitleWithSlugInput::make(
titleLabel: 'Post title',
titlePlaceholder: 'e.g. How we shipped v2',
slugLabel: 'Permalink',
urlPath: '/blog/',
titleRules: ['required', 'string', 'min:3', 'max:120'],
)->columnSpan('full');
}
}
This also gives you one place to fix if the namespace changes again (it already changed once from Camya\... to Blendbyte\...).
TitleWithSlugInput::make(
fieldTitle: 'name',
fieldSlug: 'identifier',
)
Use this on models where the slug column is handle, path, permalink, etc. Remember to update the table unique index to match.
urlVisitLinkRoute for the Visit linkThe permalink preview is display-only config (urlHost, urlPath, urlHostVisible). The Visit link should be generated from your actual route so it can never drift from the app:
TitleWithSlugInput::make(
urlHost: 'example.com', // short host, just for the preview
urlPath: '/product/',
urlVisitLinkRoute: fn (?Model $record) => $record?->slug
? route('product.show', ['slug' => $record->slug])
: null,
)
Because the link is route-driven, a bare host in the preview is fine — and on the create page $record is null, so the link cleanly disappears rather than pointing at a 404.
Lock slugs once a post is published so links don't break; keep them editable while drafting:
TitleWithSlugInput::make(
slugIsReadonly: fn ($context) => $context === 'edit',
titleIsReadonly: fn ($context) => false,
)
With slugIsReadonly === true the slug row degrades to a static permalink display — no edit link, no OK/Cancel/Reset buttons. That is usually what you want for anything with external backlinks.
Title rules are passed straight through; the slug gets a unique rule automatically. Customize it through slugRuleUniqueParameters, which maps to Filament's ->unique():
TitleWithSlugInput::make(
titleRules: ['required', 'string', 'max:120'],
slugRuleUniqueParameters: [
'ignoreRecord' => true,
'modifyRuleUsing' => fn (Unique $rule) => $rule->where('is_published', 1),
'ignorable' => fn (?Model $record) => $record,
],
)
Use modifyRuleUsing for soft-deleted rows, tenant scoping, or "unique per parent page". Use ignorable so the current record is excluded on edit. Available keys: ignorable, modifyRuleUsing, ignoreRecord, table, column.
Custom messages go on the resource, keyed on the component state path:
protected $messages = [
'data.slug.regex' => 'Invalid slug. Use only a–z, 0–9, and hyphens.',
];
If you replace Str::slug(), replace the regex in the same call or you'll produce slugs your validator rejects:
TitleWithSlugInput::make(
slugSlugifier: fn ($string) => preg_replace('/[^a-z]/', '', $string),
slugRuleRegex: '/^[a-z]*$/',
)
Keep both in one wrapper method so they can never be edited apart.
titleFieldFor spatie/filament-translatable or similar, pass the field in and let the package wire the slug generation:
TitleWithSlugInput::make(
titleField: TranslatableInput::make('title')->label('Post title')->required(),
)
Caveat: once titleField is supplied, all title* parameters (titleLabel, titleRules, titlePlaceholder, titleExtraInputAttributes) are ignored — configure them on the field itself. titleAfterStateUpdated and titleFieldWrapper still apply:
TitleWithSlugInput::make(
titleField: MyTranslatableField::make('title'),
titleAfterStateUpdated: function (Set $set, string $state) {
// runs after every title update
},
)
Works inside a Repeater as long as each nested model has its own title and slug columns:
Repeater::make('FAQEntries')
->relationship()
->collapsible()
->schema([
TitleWithSlugInput::make(
fieldTitle: 'title',
fieldSlug: 'slug',
urlPath: '/faq/',
urlHostVisible: false,
titlePlaceholder: 'Insert FAQ title...',
),
]),
Nested slugs are rarely globally unique — see the gotchas below.
The component is form-only. For the list view, render the public URL yourself:
TextColumn::make('slug')
->formatStateUsing(fn ($state) => url("/blog/{$state}"))
->url(fn ($record) => route('blog.show', $record->slug))
->openUrlInNewTab(),
And make the front end resolve slugs consistently, e.g. public function getRouteKeyName(): string { return 'slug'; } on the model.
For a pages resource where the front page has no path segment:
TitleWithSlugInput::make(slugRules: [])
An empty slug would be auto-regenerated, so / is used as the sentinel value for "homepage" — it bypasses regeneration. Handle '/' explicitly in your routing.
The namespace change is the #1 upgrade trap. Searching for TitleWithSlugInput across the codebase is not enough — grep for the old namespace Camya\Filament\Forms\Components and for any vendor:publish'd config/translations from the old package. Re-publish with --force after swapping the composer package.
Don't mix v1/v2 and v3 expectations. make() signature and parameter names are unchanged, but the CSS story differs: v3 auto-registers assets. Leaving an old manual @import in app.css produces duplicated or conflicting styles that are painful to trace. After any change here, run php artisan filament:assets (or optimize:clear) — stale Filament asset manifests are a classic "my styles didn't update" red herring.
Empty slug regenerates. You cannot have a legitimately blank slug. The documented escape hatch is slugRules: [] plus the / sentinel for a homepage. If you build any "no slug" workflow, test that it survives a title edit — an empty value will be re-slugified from the title.
Manual overrides vs. the Reset button. The inline editor exposes OK / Cancel / Reset, and Reset is the user's path back to auto-generation. Do not build your own "regenerate slug" action on top of it without testing how it interacts with the component's internal state — the component owns that cycle, not your form.
Unique rule ≠ unique index. Filament will validate uniqueness against one table/column combination, but your database index may be composite (tenant_id + slug, parent_id + slug). Set both consistently, or add modifyRuleUsing to scope the query to the tenant. This is the most common source of a production QueryException on a form that "validated fine".
Repeater uniqueness is deferred. Inside a Repeater with ->relationship(), rows may not exist in the DB at validation time, so a table-level unique rule can behave nothing like the user expects (two new rows with the same slug can both pass). Either accept duplicates inside repeaters, scope uniqueness to the parent, or validate on save.
urlHost is cosmetic; urlVisitLinkRoute is authoritative. Teams frequently hardcode urlHost: 'https://myapp.com' and then wonder why the Visit link breaks on staging or under a subdirectory install. Pass a short host for the preview and a named route for the link.
Passing titleField silently disables title* options. There's no warning — titleLabel, titleRules, titlePlaceholder, titleExtraInputAttributes just do nothing. If a title validation error isn't firing, check whether you set titleField and forgot to add ->required() to the field itself.
Custom slugifier without a matching regex ships broken data. slugSlugifier and slugRuleRegex are independent parameters. Treat them as an atomic pair, ideally inside your wrapper factory.
Scope of the package is intentionally tiny. 18 stars, 0 dependents, one active release line. That's not disqualifying — the surface area is small and the code is easy to audit / copy — but budget for it: pin the version ("blendbyte/filament-title-with-slug": "^3.0"), avoid undocumented parameters, and keep the wrapper class so that a fork, a local copy, or a hand-rolled TextInput + Set closure pair is a one-file swap if maintenance stalls. Also note the README's own caveat that PHP 8.2 is not tested against Laravel 13 — if you're on L13, run the form in a real request before committing.
Debugging checklist when the preview or slug misbehaves:
php artisan vendor:publish --tag="filament-title-with-slug-config" and confirm the file reflects what you think it does — config defaults quietly override your assumptions, and unpublished config is invisible.fieldTitle/fieldSlug change data.slug to data.<yourField>, so $messages keys and any ->afterStateUpdated() hooks must be updated too.TextInput::make('slug') from a half-finished migration — two fields on the same state path is a confusing failure mode.livewire(EditPost::class)->fillForm([...])->assertHasErrors([...])) rather than reasoning about the component's internals; the inline edit UI is a client-state concern that's easier to verify in the browser than in a unit test.Extension points worth knowing: titleAfterStateUpdated (post-title hook), titleFieldWrapper (wrap the title input for layout), titleExtraInputAttributes (styling without CSS overrides), slugRuleUniqueParameters (scoped uniqueness), slugSlugifier + slugRuleRegex (naming scheme), and urlVisitLinkRoute (link generation). Anything beyond those — multi-column composite slugs, slug history tables, slug redirects — is your code, not the package's; plan for it in a model observer or service class rather than trying to bend the field.
How can I help you explore Laravel packages today?