---
title: "Update, manual & lock policies — Sluggable for Laravel | Roundly"
description: "Control when slugs regenerate, how hand-typed values are treated, what an empty source does, and how to freeze a slug."
url: https://roundly-consulting.com/open-source/docs/sluggable-for-laravel/lifecycle
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Sluggable for Laravel](https://roundly-consulting.com/open-source/docs/sluggable-for-laravel.md)

# Update, manual & lock policies

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

| Situation | Behaviour |
| --- | --- |
| Create, empty value | Generate 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 (cut to maxLength like a generated value). Strict: keep the bytes, throw SlugAlreadyTakenException if taken, or SlugGenerationException (tooLong) when longer than maxLength — such a slug could never bind. |
| Update, Never | Nothing, even when empty. |
| Update, IfEmpty (default) | Fill an empty value or missing locales; never touch existing ones. |
| Update, WhenSourceChanges | Recompute when a source attribute changed (per locale for maps). |
| Update, Always | Recompute 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 from the source body (chair-2 moved where chair is free becomes chair, not chair-2-2) — 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

```php
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

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/sluggable-for-laravel.md)
