Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Slug Field Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

Field placement and layout

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.'),
])

Editing policies vary by state

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.

Keep generation server-side as the source of truth

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.

Uniqueness feedback in the form

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.

Reusable conventions across resources

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();
}

Testing the generation path

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');
});

Gotchas and Tips

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":

  1. Open the browser console on the Nova form — Vue errors surface there, not in laravel.log.
  2. Confirm the source field is present on the same form and is named exactly as passed to ->from() (from('title') needs a Text::make('Title') or a title attribute in the form's field set).
  3. Check ->from() isn't pointing at a BelongsTo relation — the watcher reads a string value, not an ID.
  4. Run php artisan nova:publish after any asset change, and php artisan view:clear if the form renders stale markup.
  5. Test in a fresh incognito window; Nova caches compiled assets aggressively across deploys.

Extension points are limited — plan for the fork. There's no meaningful server-side API to hook into. What you can do without forking:

  • Override display with ->displayUsing(fn ($value) => $value ?: '—').
  • Gate editability with ->readonly(...) / ->hideWhenUpdating().
  • Layer validation with ->rules(), ->creationRules(), ->updateRules().
  • Add a Nova Action for explicitly regenerating a slug on selected records — reviewed and confirmed by an editor, never automatic.

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().

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package