NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Sluggable for Laravel

Formatting & suffixes

Every slug runs through one pure pipeline — no database involved. In order:

  • Bound — scrub invalid UTF-8 and cut the source to limits.max_source_length before any regex runs.
  • Clean — drop control characters and zero-width/bidi marks; unicode mode NFKC-normalises when ext-intl is loaded.
  • Transliterate — Str::ascii() in the definition’s language (locale maps use each locale); skipped in unicode mode.
  • Separator flip — the other of - and _ becomes the separator.
  • Dictionary — replacements such as @ → at, padded with the separator.
  • Lowercase — before stripping, exactly in Str::slug()’s order.
  • Strip and collapse — keep letters, numbers and the separator; collapse runs and trim.
  • Words and length — keep the first maxWords words, then truncate to maxLength.

With the default format (ASCII, lowercase, -, ['@' => 'at']) the output matches Str::slug() for ordinary input of up to 255 characters. Two differences are deliberate:

  • Zero-width and bidi control characters are stripped before transliterating — zero\u{200B}width gives zerowidth, where Str::slug() gives zero-width.
  • Slugs are capped at max_length (255), built from the first max_source_length (2000) source characters; Str::slug() has no limit.

The facade exposes the pipeline directly:

use RoundlyConsulting\Sluggable\Facades\Slugs;

Slugs::slugify('Žltý kôň @ home', language: 'sk');     // 'zlty-kon-at-home'

Shaping the slug

SlugDefinition::for('slug')
    ->from('title')
    ->language('de')                                  // or fn (?string $locale): ?string
    ->dictionary(['&' => 'and', '@' => 'at'])
    ->maxWords(8)
    ->prefix(fn (Post $post, ?string $locale): ?string => $post->published_at?->format('Y'))
    ->reserved(['admin', 'login']);
  • separator() — 1–3 characters from - . _ ~.
  • maxLength() — 8–2048 characters including prefix, suffix and collision suffix; maxWords() caps the word count.
  • language() — a language code or fn (?string $locale): ?string; dictionary() — replacements applied before stripping.
  • unicode() keeps non-ASCII letters instead of transliterating; lowercase(false) keeps capitals.
  • prefix() / suffix() — fixed strings or closures; they run through the same pipeline and are never cut by truncation.
  • reserved() — extra words that count as taken, on top of the global reserved config (compared case-insensitively).

Truncation is multibyte-safe: it cuts at the last separator when that lies in the second half of the budget, otherwise hard-cuts, then trims separators.

Custom slugger

using() replaces the formatting steps with your own closure. Its output is still validated as a safe URL segment — letters, numbers, marks and the separator only — or SlugGenerationException (invalidCustomOutput) is thrown:

use Illuminate\Support\Str;

SlugDefinition::for('slug')->from('name')->using(
    fn (string $source, ?string $locale): string => Str::of($source)->lower()->replace(' ', '-')->toString(),
);

Collision suffixes

When the base slug is taken, a suffix is appended. Pick the strategy per definition:

use RoundlyConsulting\Sluggable\Definitions\ResolvedSlugDefinition;

SlugDefinition::for('slug')->from('name');                              // chair, chair-2, chair-3 …
SlugDefinition::for('slug')->from('name')->sequentialSuffix(start: 1);  // chair, chair-1, chair-2 …
SlugDefinition::for('slug')->from('name')->randomSuffix(6);             // chair, then chair- + 6 random [a-z0-9]
SlugDefinition::for('slug')->from('name')->suffixUsing(
    fn (string $base, int $attempt, ResolvedSlugDefinition $definition): string => 'v'.$attempt,
);

Sequential suffixes (the default, starting at 2) probe up to limits.sequential_probes candidates in batches, then fall back to random ones. randomSuffix() generates [a-z0-9] suffixes of randomLength characters (4–32). For anything else, implement the SuffixGenerator contract — it receives the base, the 1-based attempt and the resolved definition, and returns the suffix without the separator:

use RoundlyConsulting\Sluggable\Contracts\SuffixGenerator;
use RoundlyConsulting\Sluggable\Definitions\ResolvedSlugDefinition;

final class YearSuffix implements SuffixGenerator
{
    public function suffix(string $base, int $attempt, ResolvedSlugDefinition $definition): string
    {
        return now()->year.'-'.$attempt;
    }
}

SlugDefinition::for('slug')->from('name')->suffixUsing(new YearSuffix);

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

By donating, you agree to our donation terms.

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.