NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Sluggable for Laravel

Update, manual & lock policies

Each definition decides what happens on create, on update and when a user types their own slug:

SituationBehaviour
Create, empty valueGenerate from the sources; an empty source follows EmptySourcePolicy (Random by default).
Manual value (create or change)Normalize (default): run the pipeline, then make unique — a value normalising to nothing counts as empty (generated, or cleared). Verbatim: keep the bytes, make unique. Strict: keep the bytes, throw SlugAlreadyTakenException if taken.
Update, NeverNothing, even when empty.
Update, IfEmpty (default)Fill an empty value or missing locales; never touch existing ones.
Update, WhenSourceChangesRecompute when a source attribute changed (per locale for maps).
Update, AlwaysRecompute on every update.
lockWhen() true / locked()No automatic change; a manual change throws SlugLockedException (bypass: Slugs::unlocked(fn () => …)).
Scope column changed / restore with excludeTrashed()Re-check uniqueness; re-suffix (or throw for Strict).

A recomputed slug replaces the current one only when its base changed: chair-2 never churns into chair-3. The base is the one the saved sources produce, not the slug’s shape — renaming Room 101 to Room gives room (or room-2), never the stale room-101. An explicit recompute (recompute(), regenerateSlugs(), --mode=stale) also drops a suffix whose bare base is free again. Verbatim and strict values containing whitespace, control characters, /, ?, # or %, and the dot segments . and .., are rejected.

Configuring the policies

use RoundlyConsulting\Sluggable\Enums\EmptySourcePolicy;
use RoundlyConsulting\Sluggable\Enums\ManualSlugPolicy;
use RoundlyConsulting\Sluggable\Enums\UpdatePolicy;

SlugDefinition::for('slug')
    ->from('title')
    ->onUpdate(UpdatePolicy::WhenSourceChanges)       // or ->regenerateOnUpdate()
    ->manual(ManualSlugPolicy::Strict)                // taken values throw SlugAlreadyTakenException
    ->whenEmptySource(EmptySourcePolicy::Fail)        // or Random (default) / Skip
    ->lockWhen(fn (Post $post): bool => $post->is_published)
    ->skipWhen(fn (Post $post): bool => $post->is_imported);

SlugDefinition::for('handle')->from('name')->immutable();   // = onUpdate(UpdatePolicy::Never)
SlugDefinition::for('code')->from('sku')->locked();         // never changes automatically
  • onUpdate() — UpdatePolicy::Never, IfEmpty (default), WhenSourceChanges or Always. immutable() is shorthand for Never, regenerateOnUpdate() for WhenSourceChanges.
  • WhenSourceChanges recomputes per locale for maps; closure sources and relation paths always count as changed.
  • manual() — ManualSlugPolicy::Normalize (default), Verbatim or Strict.
  • whenEmptySource() — EmptySourcePolicy::Random (default: a random slug of randomLength characters), Skip or Fail (SlugGenerationException).
  • lockWhen() / locked() freeze a slug; skipWhen() skips the column for one save; onCreate(false) turns off generation on create.

Suspending, forcing and bypassing

use RoundlyConsulting\Sluggable\Enums\RegenerationMode;
use RoundlyConsulting\Sluggable\Facades\Slugs;

Slugs::withoutGeneration(fn () => Product::factory()->count(500)->create());  // imports
Product::withoutSlugGeneration(fn () => /* … */ null);
Slugs::unlocked(fn () => $post->update(['slug' => 'fixed-typo']));            // bypass locks

Slugs::recompute($product, columns: ['slug']);                               // recompute from the sources; you save
$product->regenerateSlugs(columns: ['slug'])->save();                        // the same from the model side
Slugs::regenerate($product, columns: ['slug']);                              // recompute + save
Slugs::apply($product);                                                      // run the save-time pass now (before saveQuietly/imports)
Slugs::model(Product::class)->regenerate(mode: RegenerationMode::Missing);   // back-fill a whole class

withoutGeneration() turns the hooks — and with them events and history — off; it is nestable and exception-safe. Hooks run in creating/updating, so query-builder insert()/upsert(), saveQuietly() and withoutEvents() generate nothing: call Slugs::apply($model) first. A no-op save() does not back-fill either, because Laravel fires updating only for dirty models — use regenerateSlugs(), Slugs::model(X::class)->regenerate() or sluggable:regenerate --mode=missing.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.