benjaminhirsch/nova-slug-field
A Laravel Nova field that auto-generates URL-friendly slugs from another field as you type, with support for custom separators, slug preview, and editable or read-only modes.
This package is a thin Laravel Nova form field: a Vue component that wraps Nova's Text field, watches a second field, and slugifies its value as the editor types. It touches no models, migrations, routes, or public URLs. Treat it as an admin-UI convenience and keep slug correctness in your application code.
1. Verify provenance and compatibility before you require it
The Composer name (benjaminhirsch/nova-slug-field) and the repository URL (tomhatzer/nova-slug-field) differ, so confirm the Packagist mapping and the actual source being pulled. Then check the constraint against your Nova major version — the last tag was December 2023 and Nova 4 vs. Nova 5 differ in asset handling.
composer require benjaminhirsch/nova-slug-field:^1.4
# confirm namespace, supported Nova constraint, and exposed options
cat vendor/benjaminhirsch/nova-slug-field/composer.json
ls vendor/benjaminhirsch/nova-slug-field/src
Read src/ rather than trusting the README: method names and options have shifted between minor versions, and this package's documentation is thin.
2. Build the server-side foundation first
Do this before wiring the field, so the Nova field is never the only thing producing slugs.
// migration
$table->string('slug')->unique();
// app/Models/Post.php
use Illuminate\Support\Str;
public function getRouteKeyName(): string
{
return 'slug';
}
protected static function booted(): void
{
static::saving(function (Post $post) {
if (blank($post->slug)) {
$post->slug = Str::slug($post->title);
}
});
}
The blank() guard is what stops a routine title edit from silently rewriting a live, published URL.
3. Add the field to one low-risk resource
use BenjaminHirsch\NovaSlugField\Slug; // confirm namespace from src/
public function fields(NovaRequest $request): array
{
return [
Text::make('Title')->sortable(),
Slug::make('Slug')
->from('title')
->rules('required', 'max:255')
->creationRules(Rule::unique('posts', 'slug'))
->updateRules(fn ($request, $model) => Rule::unique('posts', 'slug')->ignore($model->id))
->hideFromIndex(),
];
}
First use case: a Post resource where editors type a title and watch a slug appear, but can still correct it by hand. Place the source field above the slug field in the fields() array — the watcher needs it to exist on the form.
Ship it to staging on that single resource, create a record, edit the title on a published record, and confirm the slug does not move.
Put the source field directly above the slug field and keep them visually adjacent with ->help() text explaining that manual edits stick. On multi-column layouts (Nova 4 Flex/Field wrappers), keep both halves in the same panel — the watcher is client-side and doesn't care about ordering in the DOM, but your editors do.
Panel::make('SEO', [
Text::make('Title'),
Slug::make('Slug')->from('title')->help('Auto-generated. Edit to override.'),
])
The most useful day-to-day pattern is making the field behave differently for drafts versus published content. There's no built-in "lock after publish" switch, so express the policy in the resource layer:
Slug::make('Slug')
->from('title')
->readonly(fn () => $this->resource->exists && $this->resource->published_at !== null)
If the package's read-only flag is named differently in your installed version, fall back to ->withMeta(['readonly' => true]) or hide the field entirely with ->hideWhenUpdating() for the published case and manage slugs through a separate, gated action.
The client-side watcher never fires for seeders, imports, API writes, queued jobs, or artisan commands. Extend the model hook into a small, testable service and call it from the observer:
class SlugService
{
public function unique(string $base, ?int $ignoreId = null, string $column = 'slug'): string
{
$slug = Str::slug($base);
$original = $slug;
$i = 2;
while (Post::withTrashed()
->where($column, $slug)
->when($ignoreId, fn ($q) => $q->whereKeyNot($ignoreId))
->exists()) {
$slug = "{$original}-{$i}";
$i++;
}
return $slug;
}
}
withTrashed() matters if your unique index covers soft-deleted rows — otherwise an editor can restore a trashed record and hit a duplicate-key error at the worst possible moment.
Nova validation rules are the enforcement point; the field is not. Use creationRules for inserts and updateRules with an ignore() for updates so editors get an inline error instead of a 500 from the database. Add the DB constraint anyway — validation rules are bypassable by anything that isn't Nova.
Because this is a one-line field, it's worth standardizing it early. A tiny base resource or trait keeps every content type behaving identically:
protected function slugField(string $source = 'title'): Slug
{
return Slug::make('Slug')
->from($source)
->rules('required', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/')
->creationRules(Rule::unique($this->getModel()->getTable(), 'slug'))
->hideFromIndex();
}
Test the observer/service, not the Vue component. That's where all the correctness lives:
it('does not overwrite an existing slug on title change', function () {
$post = Post::factory()->create(['title' => 'First', 'slug' => 'first']);
$post->update(['title' => 'Second']);
expect($post->fresh()->slug)->toBe('first');
});
it('suffixes duplicates', function () {
Post::factory()->create(['slug' => 'hello']);
expect(app(SlugService::class)->unique('Hello'))->toBe('hello-2');
});
The slug only exists inside Nova. API writes, CSV imports, seeders, and queued jobs all bypass the field entirely. If you ship the field and skip the observer, you will discover this as a wave of NULL slugs in production. Always pair the field with blank($slug) generation in a saving hook.
Published URLs get silently rewritten. This is the highest-consequence failure mode: an editor fixes a typo in a title, the watcher regenerates the slug, and every inbound link, canonical tag, analytics segment, and search result for that page breaks. Guard server-side with blank($slug), and consider locking the field once published_at is set.
No uniqueness enforcement by design. The field suggests values; it does not check the database. Without a unique index you get duplicates and 404s from route('posts.show', $post) resolving the wrong row. Add the index, the validation rule, and soft-delete-aware collision handling together — one without the others is worse than nothing.
Non-Latin titles and transliteration. Str::slug() handles many scripts but not all, and the package's client-side slugifier may differ from the server-side one. Mixed results — a good slug from Nova, an empty one from the observer — are a common source of "why is this one different?" bug reports. Normalize on the server and accept that the field's preview may occasionally diverge.
Localization is out of scope. If you need per-locale slugs (/en/about, /fr/a-propos), this field won't model that. That's a translatable-attribute problem — use spatie/laravel-translatable or per-locale columns plus a custom field, and don't try to bend this one into it.
Nova upgrades break compiled assets. This field ships prebuilt JS. A Nova major bump can break it in ways that look like unrelated form failures. Isolate it: vendor a copy under nova-components/ if you need a pinned fork, and include this package in every Nova upgrade checklist. If it breaks, replacing it with a ~40-line custom Nova field is genuinely cheap — that credible fallback is the main reason this dependency is acceptable at all.
Provenance and namespace. If Slug::make() doesn't resolve, the installed namespace is the first thing to check — the Composer package name and repository owner don't match, so don't assume from the GitHub README. Grep the installed source:
grep -rn "namespace" vendor/benjaminhirsch/nova-slug-field/src
Debugging "the slug isn't appearing":
laravel.log.->from() (from('title') needs a Text::make('Title') or a title attribute in the form's field set).->from() isn't pointing at a BelongsTo relation — the watcher reads a string value, not an ID.php artisan nova:publish after any asset change, and php artisan view:clear if the form renders stale markup.Extension points are limited — plan for the fork. There's no meaningful server-side API to hook into. What you can do without forking:
->displayUsing(fn ($value) => $value ?: '—').->readonly(...) / ->hideWhenUpdating().->rules(), ->creationRules(), ->updateRules().Anything beyond that (multilingual, slug history, redirects, approval workflow) means replacing the field, not extending it. Keep your model, migration, and route-key code untouched by the package so the swap is a one-line change in fields().
How can I help you explore Laravel packages today?