PHP Packagev8.0.0MIT License

Slugify

Fast, framework-agnostic slug generation for PHP. Turn any string into a URL-friendly slug. By default it keeps Unicode letters as they are. You can also transliterate to ASCII, use any separator, split camelCase words and acronyms, and plug in your own replacement rules.

Unicode First

→ , → .

ASCII on Demand

→ , with language hints such as .

Replacement Rules

Package-wide or per slug, with no state leaking between calls.

Quick Tip

Slugify::make() covers most needs. Reach for Slugify::of() when you want a fluent, immutable builder with maxLength(), per-slug rules, or a custom transliterator.

Installation

Install the package with Composer. There is nothing to register or publish.

Requirements

  • PHP 8.0.x (each 8.x release line targets the matching PHP version)
  • ext-mbstring
  • voku/portable-ascii ^2.0 (installed automatically)
  • ext-intl (optional): normalizes decomposed Unicode input (NFC), so e + U+0301 and é produce the same slug.

Composer Installation

Terminal
composer require pharaonic/php-slugify

Composer autoloads the Pharaonic\Slugify namespace and the global slug() helper.

Installation Complete

You're all set! Try Slugify::make('Hello World'), which returns hello-world.

Basic Usage

Import the Slugify class and call make():

1use Pharaonic\Slugify\Slugify;
2 
3Slugify::make('Hello World'); // "hello-world"
4Slugify::make(' Hello World!! '); // "hello-world"
5Slugify::make('hello - _ world'); // "hello-world"
6Slugify::make('Top 10 Tips for 2026'); // "top-10-tips-for-2026"

The full signature is make(string $value, string $separator = '-', bool $ascii = false, ?string $language = null):

1Slugify::make('Hello World', '_'); // "hello_world"
2Slugify::make('Crème brûlée', '-', true); // "creme-brulee"
3Slugify::make('Äpfel und Öl', '-', true, 'de'); // "aepfel-und-oel"

The slug() Helper

The global helper takes the same arguments. It also accepts any scalar or Stringable value, and null gives an empty string.

1slug('Hello World'); // "hello-world"
2slug('Hello World', '_', true); // "hello_world"
3slug(2026); // "2026"
4slug(null); // ""

How a Slug Is Built

Each call runs the same steps in order:

  1. Repair invalid UTF-8 and, when ext-intl is installed, normalize to NFC.
  2. Split camelCase words and acronyms.
  3. Apply the replacement rules.
  4. Transliterate to ASCII (ASCII mode only).
  5. Lowercase.
  6. Keep letters, numbers and combining marks (only [A-Za-z0-9] in ASCII mode), and join the words with the separator.
  7. Apply maxLength(), if set.

Empty results are returned as "". "0" is treated as real input and gives "0".

Unicode & ASCII

Unicode Slugs (Default)

Unicode letters, numbers and combining marks are kept. Punctuation, symbols, emoji and whitespace become separators.

1Slugify::make('مرحبا بالعالم'); // "مرحبا-بالعالم"
2Slugify::make('سلام دنیا'); // "سلام-دنیا"
3Slugify::make('Привет, мир!'); // "привет-мир"
4Slugify::make('Γειά σου Κόσμε'); // "γειά-σου-κόσμε"
5Slugify::make('你好,世界'); // "你好-世界"
6Slugify::make('नमस्ते दुनिया'); // "नमस्ते-दुनिया"
7Slugify::make('Laravel مع PHP'); // "laravel-مع-php"
8Slugify::make('I ❤️ PHP'); // "i-php"

Arabic diacritics (tashkeel) and the tatweel are removed by default, so vocalized and plain spellings give the same slug:

1Slugify::make('مُحَمَّد'); // "محمد"
2Slugify::make('مـحـمـد'); // "محمد"

ASCII Slugs

Pass true as the third argument, or call ->ascii() on the fluent builder. Transliteration is handled by voku/portable-ascii.

1Slugify::make('Crème brûlée', '-', true); // "creme-brulee"
2Slugify::make('Привет мир', '-', true); // "privet-mir"
3Slugify::make('مرحبا', '-', true); // "mrhba"
4Slugify::make('你好世界', '-', true); // "ni-hao-shi-jie"
5Slugify::make('한국어', '-', true); // "hangugeo"
6 
7Slugify::of('Crème brûlée')->ascii()->toString(); // "creme-brulee"

ASCII mode always returns lowercase [a-z0-9] joined by your separator. Characters that can't be transliterated, such as emoji, are dropped.

Language Hints

Some languages transliterate letters differently. Pass a language code as the fourth argument, or to ascii():

1Slugify::make('München', '-', true); // "munchen"
2Slugify::make('München', '-', true, 'de'); // "muenchen"
3Slugify::of('Äpfel und Öl')->ascii('de')->toString(); // "aepfel-und-oel"

Any language supported by portable-ascii works, and locale forms such as de-DE are accepted. Unknown codes fall back to the generic tables.

Combining Characters

With ext-intl installed, decomposed input (e + U+0301) is composed to é first. Without it, the combining mark stays attached to its letter in Unicode mode. In ASCII mode both forms produce e.

Separators

The second argument of make(), or separator() on the builder, sets the string that joins words. The default is -.

1Slugify::make('Hello World', '-'); // "hello-world"
2Slugify::make('Hello World', '_'); // "hello_world"
3Slugify::make('Hello World', '.'); // "hello.world"
4Slugify::make('Hello World', '~'); // "hello~world"
5Slugify::make('Hello World', ''); // "helloworld"

Dashes, underscores, dots and other punctuation already in the input are treated as word breaks. Repeats are collapsed, and the slug never starts or ends with a separator:

1Slugify::make('-hello__big.. ~world-', '_'); // "hello_big_world"
2Slugify::make('hello---world'); // "hello-world"
3Slugify::make('---'); // ""
Invalid Separators

A separator must not contain letters, numbers, combining marks or whitespace. For example, 'x', '1' and ' ' throw Pharaonic\Slugify\Exceptions\InvalidArgumentException.

CamelCase & Acronyms

Words written in camelCase or PascalCase are split before slugging. Runs of capitals are kept together as acronyms.

1Slugify::make('helloWorld'); // "hello-world"
2Slugify::make('HelloWorld'); // "hello-world"
3Slugify::make('XMLHttpRequest'); // "xml-http-request"
4Slugify::make('APIResponse'); // "api-response"
5Slugify::make('getUserID'); // "get-user-id"
6Slugify::make('PharaonicPHP'); // "pharaonic-php"
7Slugify::make('Version2Beta'); // "version2-beta"
8Slugify::make('3D Printing'); // "3d-printing"
9Slugify::make('ÉtéÀParis'); // "été-à-paris"

The splitting rules are:

  • A lowercase letter followed by an uppercase letter starts a new word (helloWorld).
  • An uppercase letter or digit followed by a capitalized word starts a new word (XMLHttp, 2Beta).
  • A digit followed by a lone capital doesn't split (3D, 5G).

Turn splitting off for a single slug with splitCamelCase(false):

1Slugify::of('helloWorld')->splitCamelCase(false)->toString(); // "helloworld"

Replacement Rules

Rules replace text before the slug is built. Replacements are literal, so add spaces when the replacement should be its own word.

Package-wide Rules

1Slugify::addRule('&', ' and ');
2Slugify::make('Tom & Jerry'); // "tom-and-jerry"
3 
4Slugify::addRules([
5 'ö' => 'oe',
6 'ü' => 'ue',
7 'ä' => 'ae',
8]);
9Slugify::make('Äpfel Öl'); // "aepfel-oel"

Package-wide rules are global static state. Register them once during your app's bootstrap, for example in a Laravel service provider's boot() method.

Per-slug Rules

Rules added on the fluent builder apply to that slug only:

1Slugify::of('500$ Bill')->rule('$', ' dollar ')->toString(); // "500-dollar-bill"
2Slugify::make('500$ Bill'); // "500-bill"
3 
4Slugify::of('C++ & C#')
5 ->rules(['c++' => 'cpp', '&' => ' and ', 'c#' => 'csharp'])
6 ->toString(); // "cpp-and-csharp"

Default Rules

The package ships with two small rule sets:

RulePurpose
@ → atuser@host → user-at-host
Arabic tashkeel (U+064B–U+0652, U+0670) and tatweel (U+0640) → removedمُحَمَّد → محمد

Override or remove them like any other rule:

1Slugify::addRule('@', ' chez '); // override
2Slugify::removeRule('@'); // remove
3Slugify::resetRules(); // restore the package defaults
4Slugify::rules()->all(); // inspect: ['@' => ' at ', ...]

To ignore every default and package-wide rule for one slug, call withoutRules():

1Slugify::of('user@host')->withoutRules()->toString(); // "user-host"

How Rules Match

  • Case: when the slug is lowercased (the default), rules match case-insensitively. With lowercase(false) they match exactly.
  • Single pass: all rules are applied at once and the longest search string wins. A replacement is never re-processed by another rule.
  • Overrides: adding a rule with an existing search string replaces it, and per-slug rules override package-wide ones.
  • ASCII mode: rules whose search string contains non-ASCII characters (ö) run before transliteration. Pure-ASCII rules (allh → allah) run after it, so they also match transliterated text.
1Slugify::addRule('allh', 'allah');
2Slugify::make('بسم الله', '-', true); // "bsm-allah"

Fluent API

Slugify::of() returns a Pharaonic\Slugify\Slugger. The builder is immutable: every method returns a new instance, so you can configure one base builder and reuse it.

1use Pharaonic\Slugify\Slugify;
2 
3Slugify::of('Hello World')
4 ->separator('_')
5 ->lowercase()
6 ->toString(); // "hello_world"
7 
8(string) Slugify::of('Hello World'); // "hello-world"

Reusing a Builder

1$base = Slugify::of('Hello World');
2 
3$base->separator('_')->toString(); // "hello_world"
4$base->ascii()->toString(); // "hello-world"
5$base->toString(); // "hello-world" (unchanged)

Limiting the Length

maxLength() limits the slug to a number of characters, cutting on a word boundary. A single word longer than the limit is truncated.

1Slugify::of('The quick brown fox jumps')->maxLength(15)->toString(); // "the-quick-brown"
2Slugify::of('The quick brown fox jumps')->maxLength(14)->toString(); // "the-quick"
3Slugify::of('Supercalifragilistic')->maxLength(5)->toString(); // "super"

Keeping Case

1Slugify::of('Hello World')->lowercase(false)->toString(); // "Hello-World"
2Slugify::of('Crème Brûlée')->lowercase(false)->ascii()->toString(); // "Creme-Brulee"

Options Object

All options can be passed at once with SlugOptions. Named arguments keep it readable:

1use Pharaonic\Slugify\SlugOptions;
2 
3$options = new SlugOptions(separator: '_', ascii: true, language: 'de', maxLength: 60);
4 
5Slugify::of('München ist schön', $options)->toString(); // "muenchen_ist_schoen"
Global Rules Are Captured

Slugify::of() captures the package-wide rules when it is called. Rules added later with Slugify::addRule() don't affect builders that already exist.

Extending

Custom Transliterator

ASCII transliteration goes through the Pharaonic\Slugify\Contracts\Transliterator interface. The default implementation is PortableAsciiTransliterator. Implement the interface to use another engine, such as ICU:

app/Support/IntlTransliterator.php
1namespace App\Support;
2 
3use Pharaonic\Slugify\Contracts\Transliterator;
4 
5final class IntlTransliterator implements Transliterator
6{
7 public function transliterate(string $value, ?string $language = null): string
8 {
9 return \Transliterator::create('Any-Latin; Latin-ASCII')->transliterate($value);
10 }
11}

Use it for every slug, or for a single one:

1use App\Support\IntlTransliterator;
2use Pharaonic\Slugify\Slugify;
3 
4Slugify::useTransliterator(new IntlTransliterator()); // package-wide
5 
6Slugify::of('Привет')
7 ->ascii()
8 ->transliterator(new IntlTransliterator()) // this slug only
9 ->toString();
10 
11Slugify::useTransliterator(null); // restore the default

The transliterator only runs in ASCII mode. In ASCII mode, anything it returns outside [A-Za-z0-9] is treated as a word break.

Using Slugger Directly

Slugger can be built without the static entry point. In that case no default or package-wide rules are applied:

1use Pharaonic\Slugify\Rules\RuleSet;
2use Pharaonic\Slugify\Slugger;
3 
4(new Slugger('user@host'))->toString(); // "user-host"
5(new Slugger('user@host', null, RuleSet::defaults()))->toString(); // "user-at-host"

API Reference

Pharaonic\Slugify\Slugify

MethodDescriptionReturns
make(string $value, string $separator = '-', bool $ascii = false, ?string $language = null)Generate a slug.string
of(string $value, ?SlugOptions $options = null)Start a fluent builder that uses the current package-wide rules.Slugger
get(mixed $value, string $separator = '-', bool $ascii_only = false, ?string $ascii_lang = 'en')2.x alias of make(). Accepts scalars, Stringable and null.string
addRule(string $search, string $replace)Add or override a package-wide rule.void
addRules(array $rules)Add or override several package-wide rules.void
rule(string $key, string $value)2.x alias of addRule().void
removeRule(string $search)Remove a package-wide rule, including a default.void
rules()The current package-wide rules.RuleSet
resetRules()Restore the package default rules.void
useTransliterator(?Transliterator $transliterator)Replace the package-wide transliterator; null restores the default.void

Pharaonic\Slugify\Slugger

Every configuration method returns a new instance.

MethodDescriptionReturns
separator(string $separator)Word separator.Slugger
lowercase(bool $lowercase = true)Lowercase the slug.Slugger
ascii(?string $language = null)Transliterate to ASCII, with an optional language hint.Slugger
unicode()Turn ASCII mode off (the default).Slugger
splitCamelCase(bool $split = true)Split camelCase words and acronyms.Slugger
maxLength(?int $maxLength)Limit the length in characters; null removes the limit.Slugger
rule(string $search, string $replace)Add a rule for this slug.Slugger
rules(array $rules)Add several rules for this slug.Slugger
withoutRules()Drop the default and package-wide rules.Slugger
transliterator(Transliterator $transliterator)Use a custom transliterator for this slug.Slugger
options()A copy of the current options.SlugOptions
toString() / __toString()Build the slug.string

Pharaonic\Slugify\SlugOptions

PropertyTypeDefaultDescription
separatorstring'-'Word separator.
lowercasebooltrueLowercase the slug.
asciiboolfalseTransliterate to ASCII.
language?stringnullLanguage hint for transliteration.
splitCamelCasebooltrueSplit camelCase words and acronyms.
maxLength?intnullMaximum length in characters.

Pharaonic\Slugify\Rules\RuleSet

An immutable, ordered rule collection, returned by Slugify::rules().

MethodDescriptionReturns
RuleSet::defaults()The package default rules.RuleSet
with(string $search, string $replace)Copy with one rule added.RuleSet
merge(RuleSet|array $rules)Copy with more rules merged in.RuleSet
without(string $search)Copy without a rule.RuleSet
has(string $search)Whether a rule exists.bool
all()Every rule as search => replacement.array
isEmpty() / count()Size checks.bool / int
apply(string $value, bool $caseInsensitive = false)Apply the rules to a string.string

Other

NameDescription
slug(mixed $value, string $separator = '-', bool $ascii_only = false, ?string $ascii_lang = 'en')Global helper; same as Slugify::get().
Contracts\Transliterator::transliterate(string $value, ?string $language = null): stringTransliteration contract.
Transliteration\PortableAsciiTransliteratorDefault transliterator (voku/portable-ascii).
Exceptions\InvalidArgumentExceptionThrown for invalid separators, a maxLength below 1, empty rule search strings, and unsupported value types.

Upgrading from 2.x

The 2.x API keeps working, but some slugs come out differently. Slugs you've already stored are not affected; only newly generated slugs can change.

API Mapping

2.xv8.0.0Status
slug($value, $sep, $ascii, $lang)unchangedSupported
Slugify::get(...)Slugify::make(...)Supported alias
Slugify::rule($key, $value)Slugify::addRule($search, $replace)Supported alias
use Pharaonic\Slugify\Facades\Slugify;use Pharaonic\Slugify\Slugify;Deprecated, still works
Services\SlugifyService, Facades\FacadenoneRemoved (internal)

Output Changes

Unicode mode keeps every script. 2.x romanized some scripts and kept others:

Input2.xNow
Привет мирprivet-mirпривет-мир
Münchenmuenchenmünchen
你好世界nihaoshijie你好世界
پژوهشبزوهشپژوهش

To get Latin output, use ASCII mode: Slugify::make($title, '-', true, 'de').

Acronyms stay together. XMLHttpRequest now gives xml-http-request (2.x: x-mlhttp-request).

Fixes. '0' now gives '0' instead of ''. İstanbul gives istanbul. In ASCII mode, rules such as ö → oe now apply, @ becomes at, and CJK and Korean text is transliterated instead of dropped.

Rules apply in one pass. Adding a → b and b → c now turns ab into bc (2.x: cc).

Laravel Sluggable

pharaonic/laravel-sluggable calls slug($value, $separator, $ascii_only, $ascii_lang), and that signature is unchanged. If your app relied on the 2.x romanization, set ascii_only to true in its config.

Examples

1. Blog Post Permalinks

Generate an SEO-friendly, length-limited permalink from a post title:

app/Posts/PermalinkGenerator.php
1use Pharaonic\Slugify\Slugify;
2 
3final class PermalinkGenerator
4{
5 public function forTitle(string $title): string
6 {
7 return Slugify::of($title)->maxLength(60)->toString();
8 }
9}
10 
11(new PermalinkGenerator())->forTitle('10 Tips for Writing Clean PHP Code in 2026');
12// "10-tips-for-writing-clean-php-code-in-2026"

2. Multilingual Site

Keep native-script slugs for Arabic content, and use ASCII slugs with a language hint for German:

1use Pharaonic\Slugify\Slugify;
2 
3$slug = match ($locale) {
4 'ar', 'fa' => Slugify::make($title),
5 'de' => Slugify::make($title, '-', true, 'de'),
6 default => Slugify::make($title, '-', true),
7};
8 
9// ar: "دليل-البرمجة" from "دليل البرمجة"
10// de: "schoene-gruesse" from "Schöne Grüße"

3. Domain-specific Rules

Register package-wide rules once at boot, then use per-slug rules for one-off cases:

app/Providers/AppServiceProvider.php
1use Pharaonic\Slugify\Slugify;
2 
3public function boot(): void
4{
5 Slugify::addRules([
6 'c++' => 'cpp',
7 'c#' => 'csharp',
8 '&' => ' and ',
9 ]);
10}
app/Http/Controllers/CourseController.php
1use Pharaonic\Slugify\Slugify;
2 
3Slugify::make('C++ & C# Fundamentals'); // "cpp-and-csharp-fundamentals"
4 
5Slugify::of('Price: 100%')
6 ->rule('%', ' percent ')
7 ->toString(); // "price-100-percent"

4. File Names and Keys

Use another separator for cache keys or file names:

1use Pharaonic\Slugify\Slugify;
2 
3Slugify::make('Quarterly Report Q3', '_') . '.pdf'; // "quarterly_report_q3.pdf"
4Slugify::make('UserProfileSettings', '.'); // "user.profile.settings"

5. Reusable Configuration

Build one configured builder and reuse it for every value:

1use Pharaonic\Slugify\SlugOptions;
2use Pharaonic\Slugify\Slugify;
3 
4$options = new SlugOptions(separator: '_', ascii: true, maxLength: 32);
5 
6$keys = array_map(
7 fn (string $name) => Slugify::of($name, $options)->toString(),
8 ['Café Latte', 'Crème Brûlée', 'Pain au Chocolat']
9);
10// ["cafe_latte", "creme_brulee", "pain_au_chocolat"]

Troubleshooting

Cyrillic, Greek or Chinese titles are no longer romanized

Since the 8.x line, Unicode mode keeps every script. For Latin output, enable ASCII mode:

1Slugify::make('Привет мир', '-', true); // "privet-mir"

German umlauts become a/o/u instead of ae/oe/ue

Without a language hint, transliteration uses the generic tables. Pass de:

1Slugify::make('Äpfel', '-', true, 'de'); // "aepfel"

Or add rules: Slugify::addRules(['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue']).

My rule's replacement sticks to the next word

Replacements are literal. Add spaces around the replacement to make it a separate word:

1Slugify::of('500$')->rule('$', 'dollar')->toString(); // "500dollar"
2Slugify::of('500$')->rule('$', ' dollar ')->toString(); // "500-dollar"

A rule added at runtime doesn't apply

Slugify::of() captures the package-wide rules when it is called. Register rules before you create the builder, or add the rule to the builder with ->rule().

Rules leak between tests

Package-wide rules are static. Call Slugify::resetRules() and Slugify::useTransliterator(null) in your test's setUp()/tearDown().

InvalidArgumentException about the separator

The separator contains a letter, number, combining mark or whitespace. Use punctuation such as -, _, . or ~, or an empty string.

Decomposed and composed input give different Unicode slugs

e + U+0301 and é only match in Unicode mode when ext-intl is installed, because that's what provides NFC normalization. Install ext-intl, or use ASCII mode, where both give e.

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Slugify!

Moamen Eltouny

@MoamenEltouny

31 contributions

Chun-Sheng, Li

@peter279k

4 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.