spatie/laravel-sluggable
Generate unique slugs for Eloquent models on create/update. Supports collision suffixes, translatable slugs, and customizable slug options. Includes self-healing URLs that keep old links working via slug+ID route keys with 308 redirects to the canonical URL.
## Getting Started
### Minimal Setup
1. **Install the package**:
```bash
composer require spatie/laravel-sluggable
No additional configuration is required—just publish the package if you need to customize behavior (e.g., php artisan vendor:publish --provider="Spatie\Sluggable\SluggableServiceProvider").
Add the #[Sluggable] attribute to your Eloquent model:
use Spatie\Sluggable\Attributes\Sluggable;
#[Sluggable(from: 'title', to: 'slug')]
class Post extends Model {}
from: The source attribute (e.g., title).to: The column where the slug will be stored (e.g., slug).Add a slug column to your migration:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique(); // Optional but recommended
$table->timestamps();
});
Use it:
$post = Post::create(['title' => 'Hello World']);
echo $post->slug; // "hello-world"
create() and update() if the source field (title) changes.#[Sluggable] for simple cases (static from/to mappings).HasSlug trait for advanced logic (closures, conditional generation, custom suffixes):
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
class Post extends Model {
use HasSlug;
public function getSlugOptions(): SlugOptions {
return SlugOptions::create()
->generateSlugsFrom('title')
->saveSlugsTo('slug')
->skipGenerateWhen(fn () => $this->isDraft());
}
}
Enable for dynamic content (e.g., blog posts) to avoid broken links when titles change:
#[Sluggable(from: 'title', to: 'slug', selfHealing: true)]
class Post extends Model {
use HasSlug; // Required for self-healing
}
{post:slug-id} (e.g., /posts/hello-world-5)./posts/old-slug-5 → /posts/new-slug-5).Leverage the findBySlug() helper:
$post = Post::findBySlug('hello-world'); // Finds by slug
$post = Post::findBySlug('hello-world', ['id', 'title']); // Select specific columns
$post = Post::findBySlug('hello-world', ['*'], fn ($query) => $query->where('published', true));
Combine with spatie/laravel-translatable:
use Spatie\Sluggable\HasTranslatableSlug;
class Post extends Model {
use HasTranslatableSlug;
public function getSlugOptions(): SlugOptions {
return SlugOptions::create()
->generateSlugsFrom('title')
->saveSlugsTo('slug');
}
}
es.slug, fr.slug).Override default -1, -2 suffixes:
->usingSuffixGenerator(fn (string $slug, int $iteration) => bin2hex(random_bytes(4)))
Skip slug updates for drafts or specific states:
->skipGenerateWhen(fn () => $this->state === 'draft')
Ensure slugs are unique within a scope (e.g., tenant_id):
->extraScope(fn ($query) => $query->where('tenant_id', $this->tenant_id))
Missing HasSlug Trait for Self-Healing
use HasSlug; to override getRouteKey() and resolveRouteBinding().selfHealing: true.Slug Column Not Unique
-1, -2, etc., but a UNIQUE constraint in the DB prevents duplicate slugs at the database level.->unique() to the slug column in migrations for safety.Case Sensitivity in Slugs
->generateSlugsFrom(fn ($title) => strtoupper($title)) for uppercase slugs.utf8mb4_unicode_ci) may affect uniqueness checks.Translatable Slugs Without Translatable Package
HasTranslatableSlug requires spatie/laravel-translatable. Install it first:
composer require spatie/laravel-translatable
Route Binding Conflicts
selfHealing: false, ensure your route binding matches the slug column:
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
selfHealing: true for dynamic content to avoid broken links.Performance with Large Datasets
UNIQUE index on the slug column.extraScope() to limit the uniqueness check to a subset of records.Check Slug Generation
generateSlug() to log the generated slug:
public function generateSlug(): void {
$slug = $this->getSlugOptions()->generateSlug($this);
logger()->debug("Generated slug: {$slug}");
$this->forceFill(['slug' => $slug]);
}
Verify Route Binding
Route::get('/posts/{post:slug-id}', [PostController::class, 'show']);
php artisan route:list to confirm the binding works.Handle Collisions Manually
->usingSuffixGenerator(fn ($slug, $iteration) => "-{$iteration}-custom")
Custom Slug Generator
config/sluggable.php):
'generator' => \App\Services\CustomSlugGenerator::class,
Spatie\Sluggable\SlugGenerator interface.Override Self-Healing Logic
Spatie\Sluggable\SelfHealingUrlGenerator.Add Slug to API Responses
public function toArray() {
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->title,
];
}
Bulk Slug Regeneration
Post::all()->each(fn ($post) => $post->generateSlug()->save());
spatie/laravel-activitylog: Track slug changes for auditing:
use Spatie\Activitylog\LogOptions;
protected static $logAttributes = ['slug'];
public function test_slug_generation_with_special_chars() {
$post = Post::create(['title' => 'Hello @World!']);
$this->assertEquals('hello-world', $post->slug);
}
$slug = cache()->remember("slug-{$post->id}", now()->addHours(1
How can I help you explore Laravel packages today?