rudak/slug-bundle
Symfony bundle that simplifies URL slug management for Doctrine entities. Configure slug and source fields, automatically generate transliterated slugs, handle uniqueness, and keep routing stable as content changes. A lightweight way to add clean, SEO-friendly URLs to your app.
Do not composer require this into a Laravel app. rudak/slug-bundle is a Symfony2 bundle: it registers through AppKernel::registerBundles(), ships a DI service definition in Resources/config/services.*, and exposes its functionality as a Twig filter. Laravel has none of those extension points — no bundle registration, no Twig in Blade, and symfony/framework-bundle ^2.x / twig/twig ^1.x constraints will normally dead-end Composer against PHP 8.x and modern Laravel.
So "getting started" for a Laravel developer means one of two paths:
Path A — the 99% case: use the framework.
# nothing to install; Str::slug ships with Laravel
use Illuminate\Support\Str;
Str::slug('Hello World!'); // "hello-world"
Str::slug('Héllo Wörld'); // "hello-world"
Str::slug('Hello World', '_'); // "hello_world"
Str::slug('Привет мир', '-', 'ru'); // locale-aware transliteration
This is the same concern the bundle solves, in-core, maintained, and locale-aware. Treat the bundle as a data point in the "library or one line?" evaluation, not as a dependency.
Path B — you need byte-for-byte parity with a legacy Symfony2 app's slugs (SEO contracts, persisted URLs, redirect history). Then you do not install the bundle; you read it and port the algorithm:
vendor/rudak/slug-bundle/ and vendor/rudak/slugger/ (the underlying rudak/Slugger repo). The slug logic is small — typically one class with a slugify() method, a separator argument, and a transliteration map.app/Support/LegacySlugger.php, preserving the transliteration table and separator behavior exactly.First use case (either path): slugify a post title before persisting.
$post->slug = Str::slug($request->validated('title'));
Then read it back in a route with implicit binding by slug (see below).
Where to look first: Str::slug() docs and voku/portable-ascii (the transliteration engine behind it). In the vendor package, look at the service definition to learn the default separator and casing — that's the only behavior you might need to replicate.
1. Centralize behind one app-owned helper. Even if it's three lines today, wrap it so you can swap implementations later (including to a ported legacy algorithm).
// app/Support/Slugger.php
namespace App\Support;
use Illuminate\Support\Str;
class Slugger
{
public function make(string $text, string $separator = '-'): string
{
return Str::slug($text, $separator);
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(\App\Support\Slugger::class);
2. Generate on write, never on read. Use a model observer or booted() hook so forms, seeders, imports, and console commands all go through the same path.
// app/Models/Post.php
protected static function booted(): void
{
static::saving(function (Post $post) {
if (blank($post->slug)) {
$post->slug = app(\App\Support\Slugger::class)->make($post->title);
}
});
}
blank() (not empty slug check on every save) preserves the editorial override: once a human sets a slug, you stop regenerating it.
3. Uniqueness is your job, not the package's. The bundle does not resolve collisions. Combine an app-level suffixer with a database unique index — the index is the only real guarantee.
public function unique(string $base, string $modelClass, ?int $ignoreId = null): string
{
$slug = $base; $i = 1;
while ($modelClass::where('slug', $slug)
->when($ignoreId, fn ($q) => $q->whereKeyNot($ignoreId))
->exists()) {
$slug = $base.'-'.++$i;
}
return $slug;
}
$table->string('slug')->unique();
And wrap the insert in a retry, because two concurrent requests can both pass the exists() check:
retry(3, fn () => $post->save(), fn ($e) => $e instanceof QueryException, 50);
4. Route model binding by slug. This is the payoff for all of the above.
// app/Models/Post.php
public function getRouteKeyName(): string
{
return 'slug';
}
Route::get('/posts/{post}', [PostController::class, 'show']);
5. Blade: just call the helper. There is no Twig slugify filter to port; there's no Blade equivalent needed.
<a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
{{-- for non-model strings: --}}
<a href="/tag/{{ \Illuminate\Support\Str::slug($tag) }}">{{ $tag }}</a>
Better: precompute slugs on the tag model and store them, so URLs are stable if the display name changes.
6. Validate editorial input in a FormRequest. If you allow slug overrides, constrain the charset so you never produce URLs the router can't match.
'slug' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('posts', 'slug')->ignore($this->post)],
7. Backfill as an artisan command. Legacy migrations and imports (the exact scenario where you'd be tempted to reach for the bundle) mean rows with no slug.
Post::whereNull('slug')->orWhere('slug', '')->chunkById(500, function ($posts) {
foreach ($posts as $post) {
$post->slug = $this->slugger->unique($this->slugger->make($post->title), Post::class, $post->id);
$post->save();
}
});
8. If porting legacy logic: pin behavior with golden tests first. Capture real inputs/outputs from the old Symfony2 app (a CSV of title → slug), commit it as a fixture, and assert the ported class reproduces it exactly. That fixture is your migration acceptance criteria.
symfony/framework-bundle / twig/twig version conflicts. A disposable composer require --dry-run in a scratch project confirms the diagnosis in a minute — do that before spending more time on integration design.{{ x|slugify }} has no Laravel equivalent and no Blade directive ships with the package. You would be writing a Blade directive that just calls Str::slug.Str::slug can return an empty string. Cyrillic, CJK, Arabic, and emoji-only titles transliterate to nothing depending on locale. Always fall back: Str::slug($title) ?: 'post-'.Str::random(8).-2 suffix, or you'll blow the column width exactly when you hit a duplicate.Post and post collide in SQL but not in your PHP exists() check if you compare loosely. Keep the slug column in a binary/utf8mb4_bin collation, or normalize to lowercase at write time — the package's casing option is exactly this concern, and you should own it explicitly.slug_history table or a previous_slug column and 301 the old value. Neither this package nor Str::slug does that.title and slug as separate columns; the slug is a derived identifier for routes, cache keys, and file names — never for display.dd(Str::slug($input)) (or the ported method) with the exact input — the divergence is almost always the transliteration of a specific character, so diff the two outputs character by character.Slugger contract in app/Contracts/ with a Str-backed implementation. That's the seam that makes a future move to cocur/slugify, a legacy-parity port, or a transliteration-rules engine a one-binding change instead of a grep-and-replace.rudak/slug-bundle is Symfony2-era, unmaintained, and has no meaningful adoption signal. Log it as EOL in your SBOM, don't add references to it, and let the in-core slugger cover the requirement — skipping it costs you no capability.How can I help you explore Laravel packages today?