keyvanakbary/slugifier
keyvanakbary/slugifier is a lightweight PHP library for turning text into clean, URL-friendly slugs. It supports UTF-8 transliteration, customizable separators and transformations, and works standalone or in Laravel via Composer.
This is a deliberately tiny, framework-agnostic library: one class, no service provider, no config file. The fastest path is to read the single source file before wiring anything up, because the repo metadata here is stale (last release 2015) and the API surface is small enough to verify in a minute.
Install it:
composer require keyvanakbary/slugifier
Open vendor/keyvanakbary/slugifier/src/ and read the Slugifier class. That is the whole package — confirm the constructor signature and whether slugify() is an instance or static call in your installed version before writing call sites.
Use it directly for the first slug:
use Keyvanakbary\Slugifier\Slugifier;
$slugifier = new Slugifier();
$slug = $slugifier->slugify('Hello, Laravel World!'); // "hello-laravel-world"
First real use case: give a Post model a slug column and generate it from the title when the model is created or when the title changes.
// database migration
$table->string('slug')->unique();
Because it is pure and stateless, you can instantiate it anywhere — a controller, an observer, a queued job, a factory. There is no bootstrapping and nothing to register.
Wrap it in a small application service. Don't sprinkle new Slugifier() through controllers. Create an app-level SlugService that owns the slugifier and the uniqueness policy. This gives you one place to swap in a different library later and one place to test.
namespace App\Support;
use Keyvanakbary\Slugifier\Slugifier;
class SlugService
{
public function __construct(private Slugifier $slugifier) {}
public function make(string $value): string
{
return $this->slugifier->slugify($value);
}
}
Bind it as a singleton in AppServiceProvider — safe because the underlying object holds no state.
$this->app->singleton(SlugService::class, fn () => new SlugService(new Slugifier()));
Auto-populate on the model. A booted() hook keeps slugs consistent regardless of whether the record is created from a controller, a factory, or an importer:
protected static function booted(): void
{
static::saving(function (self $post) {
if ($post->isDirty('title') || blank($post->slug)) {
$post->slug = app(SlugService::class)->unique($post->title, $post->id);
}
});
}
Handle uniqueness yourself. The library only transforms text; it has no concept of collisions. Use a bounded loop rather than recursing forever:
public function unique(string $value, ?int $ignoreId = null): string
{
$base = $this->make($value);
$slug = $base;
$i = 2;
while (Post::where('slug', $slug)->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId))->exists()) {
$slug = "{$base}-{$i}";
$i++;
if ($i > 100) { throw new \RuntimeException("Cannot slugify: {$value}"); }
}
return $slug;
}
Form requests. If you accept a user-supplied slug, normalise it in prepareForValidation() so the rest of the request lifecycle only ever sees clean input:
protected function prepareForValidation(): void
{
if ($this->filled('slug')) {
$this->merge(['slug' => app(SlugService::class)->make($this->input('slug'))]);
}
}
Route model binding. Once the column exists, point binding at it and keep IDs out of URLs:
public function getRouteKeyName(): string { return 'slug'; }
Seeders, imports, and backfills. The pure-function nature makes it ideal for batch jobs and one-off artisan commands — no locale or container state to worry about, so a chunkById backfill is trivial.
Defensive wrapper for fallbacks. Slugs can come out empty for input that is entirely punctuation or non-Latin. Decide the policy once in your service (e.g. fall back to Str::random(8)) rather than at each call site.
php -l plus a smoke test in a test case will tell you. If it trips deprecation notices, the fix is usually a one-line patch, but that means forking or replacing it.slug columns are usually VARCHAR(255) and unique. Truncate the base before appending the -2, -3 suffix, or you will hit index-length errors on long titles, especially with MySQL utf8mb4.isDirty('title')) or the slug is blank, and consider keeping old slugs in a redirect table.var_dump the raw input first — most surprises are encoding issues (non-UTF-8 source data) rather than the slugifier.Str::slug() or cocur/slugify without touching models or controllers.How can I help you explore Laravel packages today?