Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Slug Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Open 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.
  2. Copy that class into app/Support/LegacySlugger.php, preserving the transliteration table and separator behavior exactly.
  3. Bind it behind an interface in a service provider so the rest of the app never references the legacy class directly.

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.

Implementation Patterns

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.

Gotchas and Tips

  • Composer will fight you. Expect 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.
  • The Twig filter is a dead end in Blade. Any documentation example using {{ 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).
  • Max length truncation causes collisions. Truncate the base to something under your index size (255 is fine; 191 if you're on an old utf8mb4 index limit) before appending the -2 suffix, or you'll blow the column width exactly when you hit a duplicate.
  • Slug lookups are collation-dependent. With a case-insensitive collation, 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.
  • Slugs are user-visible URLs once shipped. Treat a slug change as a redirect obligation: store a slug_history table or a previous_slug column and 301 the old value. Neither this package nor Str::slug does that.
  • Don't slug source-of-truth titles. Keep title and slug as separate columns; the slug is a derived identifier for routes, cache keys, and file names — never for display.
  • Debugging is trivial by design. There's no config, no cache, no container magic to clear. If a slug looks wrong, 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.
  • Extension point worth building anyway: a 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.
  • If you're auditing it: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib