oliwol/laravel-slugify

A trait to easily add slug generation to your Laravel models.

Maintainers

Package info

github.com/oliwol/laravel-slugify

pkg:composer/oliwol/laravel-slugify

Statistics

Installs: 710

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.4.0 2026-04-11 20:32 UTC

README

Latest Version on Packagist GitHub Tests Action Status License

Documentation | Migrating from Spatie

A tiny trait that gives your Eloquent models clean, automatic slugs β€” without setup, ceremony, or extra weight.

Attach it to a model, define the source (an attribute, multiple attributes, or a method), and the trait quietly handles generation, updates and uniqueness.

πŸš€ Installation

Install the package via Composer:

composer require oliwol/laravel-slugify

⚑️ Quick Start

Using the PHP Attribute (recommended)

use Oliwol\Slugify\HasSlug;
use Oliwol\Slugify\Slugify;

#[Slugify(from: 'title', to: 'slug')]
class Post extends Model
{
    use HasSlug;
}

Using method overrides

use Oliwol\Slugify\HasSlug;

class Post extends Model
{
    use HasSlug;

    public function getAttributeToCreateSlugFrom(): string|array
    {
        return 'title';
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

Priority: Method overrides always take precedence over the #[Slugify] attribute.

πŸ› οΈ Usage

Add the HasSlug trait to any Eloquent model where a slug should be automatically generated.

Configuration via #[Slugify] Attribute

The #[Slugify] attribute accepts the following parameters:

  • from (required) β€” the source for the slug. Accepts a single attribute name (e.g. 'name'), an array of attribute names (e.g. ['first_name', 'last_name']), or a method name on the model (e.g. 'getFullTitle'). When a method name is given, it is called to produce the slug string and dirty detection is skipped (the slug is always regenerated on save).
  • to (optional) β€” the column to save the slug to. Falls back to getRouteKeyName() if omitted.
  • separator (optional) β€” the character used to separate words in the slug. Defaults to '-'.
  • maxLength (optional) β€” maximum number of characters for the slug. Truncates at word boundaries. Defaults to null (no limit).
  • regenerateOnUpdate (optional) β€” whether to regenerate the slug when the source attribute changes on update. Defaults to true. Set to false to only generate slugs on creation (useful for SEO).
use Oliwol\Slugify\HasSlug;
use Oliwol\Slugify\Slugify;

// Full configuration via attribute
#[Slugify(from: 'name', to: 'slug')]
class Post extends Model
{
    use HasSlug;
}

// Only 'from' β€” slug column is determined by getRouteKeyName()
#[Slugify(from: 'name')]
class Post extends Model
{
    use HasSlug;

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

// Multiple source attributes β€” generates slug from combined values
#[Slugify(from: ['first_name', 'last_name'], to: 'slug')]
class Author extends Model
{
    use HasSlug;
}
// first_name: "John", last_name: "Doe" β†’ "john-doe"

// Custom separator β€” uses underscores instead of hyphens
#[Slugify(from: 'title', to: 'slug', separator: '_')]
class Post extends Model
{
    use HasSlug;
}
// "Hello World" β†’ "hello_world"

// Method source β€” use a model method for complex slug generation
#[Slugify(from: 'getFullTitle', to: 'slug')]
class Post extends Model
{
    use HasSlug;

    public function getFullTitle(): string
    {
        return $this->category->name . ' ' . $this->title;
    }
}
// category: "Tech", title: "Laravel Tips" β†’ "tech-laravel-tips"
// Note: dirty detection is skipped β€” the slug is always regenerated on save.

// SEO-safe β€” slug is only generated on creation, never updated
#[Slugify(from: 'title', to: 'slug', regenerateOnUpdate: false)]
class Post extends Model
{
    use HasSlug;
}

Note: The to parameter only controls where the slug is saved. For route model binding, you still need to override getRouteKeyName() separately on your model.

Configuration via methods

Alternatively, you can configure slug generation by overriding methods:

  • getAttributeToCreateSlugFrom() β€” the attribute(s) used to generate the slug. Return a string or array<string>.
  • getRouteKeyName() β€” the slug column for route model binding (e.g. slug).
  • Optionally getAttributeToSaveSlugTo() β€” a different column to save the slug.
  • Optionally getSlugSeparator() β€” the separator character (default '-').
  • Optionally getMaxSlugLength() β€” maximum slug length, truncated at word boundaries (default null).
  • Optionally shouldRegenerateSlugOnUpdate() β€” return false to only generate slugs on creation (default true).
  • Optionally override scopeSlugQuery() β€” scoping for uniqueness (e.g. per team).
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Oliwol\Slugify\HasSlug;

class Post extends Model
{
    use HasSlug;

    /**
     * Attribute(s) used for generating the slug.
     * Return a string or an array of strings.
     */
    public function getAttributeToCreateSlugFrom(): string|array
    {
        return 'name';
    }

    /**
     * Use slug for route binding.
     */
    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    /**
     * This package uses Laravel's getRouteKeyName to store the slug.
     * If you are using a different column for your routes,
     * use getAttributeToSaveSlugTo to store the slug.
     */
    public function getAttributeToSaveSlugTo(): string
    {
        return 'slug';
    }

    /**
     * Scope applied when checking for uniqueness.
     */
    public function scopeSlugQuery($query)
    {
        return $query->where('tenant_id', 1);
    }
}

Make sure your table contains the slug column:

$table->string('slug')->unique();

If you use scoping, you probably don’t want a global unique index. Example: slugs must be unique per tenant:

$table->unique(['tenant_id', 'slug']);

βš™οΈ How it works

The HasSlug trait hooks into the Eloquent saving event:

protected static function bootHasSlug(): void
 {
     static::saving(function (Model $model): void {
         if ($model->isSluggable()) {
             $model->createSlug();
         }
     });
 }

When triggered, it will:

  1. Resolve the source β€” from the #[Slugify] attribute or a getAttributeToCreateSlugFrom() override. Supports a single attribute, multiple attributes, or a model method name.

  2. Generate a slug β€” if the source is a method, call it; otherwise combine filled attribute values (null/empty values are skipped).

  3. Skip regeneration if:

    1. Using attribute source(s) and none are dirty (unchanged), or
    2. The slug has been manually set and differs from the original.

    Note: When using a method source, dirty detection is skipped β€” the slug is always regenerated on save, since the trait cannot track the method's dependencies.

  4. Ensure uniqueness by incrementing existing slugs (my-post, my-post-2, my-post-3, …).

πŸ”Ž Finding Models by Slug

The trait provides two static methods to look up models by their slug:

// Returns the model or null
$post = Post::findBySlug('hello-world');

// Returns the model or throws ModelNotFoundException
$post = Post::findBySlugOrFail('hello-world');

Both methods respect the configured slug column (to / getAttributeToSaveSlugTo()) and apply scopeSlugQuery() for scoped lookups.

πŸ“œ Slug History

When slugs change (e.g. because a title was updated), old URLs break. The optional HasSlugHistory trait keeps track of previous slugs so you can implement 301 redirects from old URLs to new ones β€” critical for SEO.

Setup

Publish and run the migration:

php artisan vendor:publish --tag=slugify-migrations
php artisan migrate

This creates a slug_history table that stores old slugs with a polymorphic relation to your models.

Usage

Add the HasSlugHistory trait alongside HasSlug:

use Oliwol\Slugify\HasSlug;
use Oliwol\Slugify\HasSlugHistory;
use Oliwol\Slugify\Slugify;

#[Slugify(from: 'title', to: 'slug')]
class Post extends Model
{
    use HasSlug, HasSlugHistory;
}

When a slug changes, the old slug is automatically recorded in the slug_history table. Duplicate entries are prevented β€” if a model cycles back to a previous slug, it won't be stored again.

Finding models by current or historical slug

Use findBySlugWithHistory() to look up a model by its current slug or any previous slug:

$post = Post::create(['title' => 'Laravel Tips']);
// slug: "laravel-tips"

$post->update(['title' => 'Advanced Laravel Tips']);
// slug: "advanced-laravel-tips"
// history: ["laravel-tips"]

// Find by current slug
Post::findBySlugWithHistory('advanced-laravel-tips'); // β†’ Post

// Find by old slug (useful for 301 redirects)
Post::findBySlugWithHistory('laravel-tips'); // β†’ Post

// Returns null if neither current nor historical slug matches
Post::findBySlugWithHistory('nonexistent'); // β†’ null

Implementing 301 redirects

A typical use case is redirecting old URLs to the current one in a controller:

public function show(string $slug)
{
    $post = Post::findBySlugWithHistory($slug);

    if (! $post) {
        abort(404);
    }

    // If the slug doesn't match the current one, redirect
    if ($post->slug !== $slug) {
        return redirect()->route('posts.show', $post->slug, 301);
    }

    return view('posts.show', compact('post'));
}

Accessing slug history

You can access all previous slugs of a model via the slugHistory relationship:

$post->slugHistory; // Collection of SlugHistory entries

$post->slugHistory->pluck('slug'); // ["old-slug", "older-slug"]

// Each entry is timestamped
$post->slugHistory->first()->created_at; // Carbon instance

🌍 Translatable Slugs

For multilingual applications, the optional HasTranslatableSlug trait integrates with spatie/laravel-translatable to generate one slug per locale (e.g. /en/hello-world and /de/hallo-welt).

Setup

Install spatie/laravel-translatable:

composer require spatie/laravel-translatable

In your migration, define the source and slug columns as json:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->json('title')->nullable();
    $table->json('slug')->nullable();
});

Usage

Use HasTranslatableSlug instead of HasSlug and add spatie's HasTranslations trait:

use Oliwol\Slugify\HasTranslatableSlug;
use Oliwol\Slugify\Slugify;
use Spatie\Translatable\HasTranslations;

#[Slugify(from: 'title', to: 'slug')]
class Post extends Model
{
    use HasTranslations, HasTranslatableSlug;

    public array $translatable = ['title', 'slug'];
}

Generating slugs per locale

When the model is saved, a slug is generated for each locale that has a value in the source attribute:

$post = Post::create([
    'title' => ['en' => 'Hello World', 'de' => 'Hallo Welt'],
]);

$post->getTranslation('slug', 'en'); // β†’ 'hello-world'
$post->getTranslation('slug', 'de'); // β†’ 'hallo-welt'

Per-locale uniqueness

Uniqueness is checked per locale using JSON-path queries. Two models can share the same English slug as long as their other locales differ β€” but within a single locale, suffixes are appended:

Post::create(['title' => ['en' => 'Hello', 'de' => 'Erster']]);
$second = Post::create(['title' => ['en' => 'Hello', 'de' => 'Zweiter']]);

$second->getTranslation('slug', 'en'); // β†’ 'hello-2'  (incremented)
$second->getTranslation('slug', 'de'); // β†’ 'zweiter'  (untouched)

Finding models by translated slug

findBySlug() accepts an optional $locale parameter (defaults to app()->getLocale()):

Post::findBySlug('hello-world', 'en'); // β†’ Post
Post::findBySlug('hallo-welt', 'de');  // β†’ same Post

// Without explicit locale, uses the current app locale:
app()->setLocale('de');
Post::findBySlug('hallo-welt'); // β†’ Post

Method sources

Method sources work too β€” the method is called once per locale with the locale context active:

#[Slugify(from: 'getFullTitle', to: 'slug')]
class Post extends Model
{
    use HasTranslations, HasTranslatableSlug;

    public array $translatable = ['title', 'slug'];

    public function getFullTitle(): string
    {
        // $this->getLocale() reflects the current locale being generated.
        return 'post-' . $this->getTranslation('title', $this->getLocale(), false);
    }
}

Note: When using a method source with HasTranslatableSlug, the available locales are gathered from all translatable attributes on the model (excluding the slug target). For attribute sources, locales come from the source attribute itself.

Limitation: HasTranslatableSlug only supports a single attribute name or a method name as source. Array sources (e.g. from: ['first_name', 'last_name']) are not supported β€” use a method source instead to combine multiple translatable values.

πŸ“‘ Events

The package dispatches events during the slug lifecycle, allowing you to hook in for logging, cache invalidation, or redirect management.

Available events

Event Dispatched when Properties
Oliwol\Slugify\Events\SlugGenerated A slug is created for the first time $model, $slug
Oliwol\Slugify\Events\SlugUpdated An existing slug changes to a new value $model, $oldSlug, $newSlug

Events are only dispatched when the slug actually changes β€” if a save results in the same slug value, no event is fired.

Listening to events

Register listeners in your EventServiceProvider or use closures:

use Oliwol\Slugify\Events\SlugGenerated;
use Oliwol\Slugify\Events\SlugUpdated;

// In EventServiceProvider::$listen or via Event::listen()
Event::listen(SlugGenerated::class, function (SlugGenerated $event) {
    Log::info("Slug created: {$event->slug}", [
        'model' => get_class($event->model),
        'id' => $event->model->getKey(),
    ]);
});

Event::listen(SlugUpdated::class, function (SlugUpdated $event) {
    Log::info("Slug changed: {$event->oldSlug} β†’ {$event->newSlug}", [
        'model' => get_class($event->model),
        'id' => $event->model->getKey(),
    ]);

    // Example: create a redirect entry
    Redirect::create([
        'from' => $event->oldSlug,
        'to' => $event->newSlug,
    ]);
});

Combining with Slug History

When using both HasSlugHistory and events, the slug history is recorded automatically by the trait while events give you additional flexibility for custom logic. They work independently and can be used together or separately.

πŸ”§ Artisan Command

The package provides an Artisan command to generate or regenerate slugs for existing database records β€” useful when adding the package to an existing project or after changing slug configuration.

Generate missing slugs

php artisan slugify:generate "App\Models\Post"

This processes all records where the slug column is null or empty, generates a slug from the configured source attribute(s), and saves the result. Records that already have a slug are skipped.

Overwrite existing slugs

Use --force to regenerate slugs for all records, including those that already have one:

php artisan slugify:generate "App\Models\Post" --force

Preview changes

Use --dry-run to see how many slugs would be generated without actually saving anything:

php artisan slugify:generate "App\Models\Post" --dry-run

Performance

The command processes records in chunks of 200 and displays a progress bar, making it safe to use on large datasets without running into memory issues.

βœ… Best practices & caveats

  • Ensure the route key column (getRouteKeyName()) is present in your table and is not the primary key (unless intentionally designed).
  • If you manually set a slug, the trait will not override it. Use this to allow user-edited slugs.

πŸ” Custom Scoping Example

To ensure slugs are unique per tenant, override the scopeSlugQuery() method:

public function scopeSlugQuery($query)
{
    return $query->where('tenant_id', 1);
}

This will append a WHERE tenant_id = ? clause when checking for existing slugs.

πŸ“„ License

This package is open-sourced software licensed under the MIT license.