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.
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.xrelease line targets the matching PHP version) ext-mbstringvoku/portable-ascii^2.0 (installed automatically)ext-intl(optional): normalizes decomposed Unicode input (NFC), soe+ U+0301 andéproduce the same slug.
Composer Installation
composer require pharaonic/php-slugifyComposer autoloads the Pharaonic\Slugify namespace and the global slug() helper.
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:
- Repair invalid UTF-8 and, when
ext-intlis installed, normalize to NFC. - Split camelCase words and acronyms.
- Apply the replacement rules.
- Transliterate to ASCII (ASCII mode only).
- Lowercase.
- Keep letters, numbers and combining marks (only
[A-Za-z0-9]in ASCII mode), and join the words with the separator. - 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.
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('---'); // ""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:
| Rule | Purpose |
|---|---|
@ → at | user@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 '); // override2Slugify::removeRule('@'); // remove3Slugify::resetRules(); // restore the package defaults4Slugify::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"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:
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 defaultThe 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
| Method | Description | Returns |
|---|---|---|
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.
| Method | Description | Returns |
|---|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
separator | string | '-' | Word separator. |
lowercase | bool | true | Lowercase the slug. |
ascii | bool | false | Transliterate to ASCII. |
language | ?string | null | Language hint for transliteration. |
splitCamelCase | bool | true | Split camelCase words and acronyms. |
maxLength | ?int | null | Maximum length in characters. |
Pharaonic\Slugify\Rules\RuleSet
An immutable, ordered rule collection, returned by Slugify::rules().
| Method | Description | Returns |
|---|---|---|
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
| Name | Description |
|---|---|
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): string | Transliteration contract. |
Transliteration\PortableAsciiTransliterator | Default transliterator (voku/portable-ascii). |
Exceptions\InvalidArgumentException | Thrown 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.x | v8.0.0 | Status |
|---|---|---|
slug($value, $sep, $ascii, $lang) | unchanged | Supported |
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\Facade | none | Removed (internal) |
Output Changes
Unicode mode keeps every script. 2.x romanized some scripts and kept others:
| Input | 2.x | Now |
|---|---|---|
Привет мир | privet-mir | привет-мир |
München | muenchen | mü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).
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:
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:
1use Pharaonic\Slugify\Slugify; 2 3public function boot(): void 4{ 5 Slugify::addRules([ 6 'c++' => 'cpp', 7 'c#' => 'csharp', 8 '&' => ' and ', 9 ]);10}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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.