jbroadway/urlify
A PHP slug generator that transliterates Unicode text to ASCII and creates clean, SEO-friendly URLs. Supports multiple languages, custom character mappings, and Laravel integration for easy slug creation.
Install
composer require jbroadway/urlify
There is nothing to publish, no service provider, no config file, and no facade. It is a single static class autoloaded via Composer. Just import and call:
use jbroadway\URLify;
echo URLify::filter('Hello, Wörld!'); // hello-world
echo URLify::filter('Hello, Wörld!', 200, 'de'); // hello-woerld
That is the entire "getting started". The class is jbroadway\URLify.
Where to look first
src/URLify.php — the whole API (filter, downcode, addChars, removeWords).src/URLify/maps/ — per-language transliteration tables (one file per locale). This is where the de → ue, tr → i, etc. behavior comes from.First use case
Slugging a model title on create. The most direct integration is a saving hook:
protected static function booted(): void
{
static::saving(function (self $model) {
$model->slug ??= URLify::filter($model->title);
});
}
That is the 90% case: filter() returns a lowercase ASCII string with - separators, safe for URLs, ready for a unique column.
Wrap it — don't scatter static calls. A small App\Support\Slug helper or an injectable Slugger service gives you one place to configure language, max length, and stop words, and it keeps URLify calls out of Blade templates and closures:
namespace App\Support;
use jbroadway\URLify;
class Slug
{
public static function make(string $value, ?string $language = null, int $max = 200): string
{
return URLify::filter($value, $max, $language ?? config('slugs.language', ''));
}
}
Locale-aware slugs. Pass the $language argument to get correct transliteration rules. de maps ü → ue and ß → ss; tr handles the dotted/dotless i; da/no handle æ, ø, å. If your app is multi-locale, resolve the language from app()->getLocale() (or a per-model slugLocale property) rather than hard-coding it, so slugs stay stable when editors switch UI language.
Slug uniqueness belongs to you. filter() does not check for collisions and does not append suffixes. Implement that explicitly:
$base = Slug::make($model->title);
$slug = $base;
$i = 2;
while (static::where('slug', $slug)->exists()) {
$slug = "{$base}-{$i}";
$i++;
}
$model->slug = $slug;
Put this in a dedicated action or observer so it also runs during imports and seeding.
Use downcode() for search, not slugs. URLify::downcode($text) strips accents and non-ASCII but preserves casing, spaces, and punctuation. That is the right tool for normalizing a searchable column or a "name" index, while filter() is the right tool for a URL segment.
Custom character maps at boot. If your domain has recurring symbols (currency, ligatures, brand marks), register them once:
// config/slugs.php returns char => replacement
URLify::addChars(config('slugs.custom_chars', []));
Do this in a service provider's boot(), not inside a request handler, so the mappings apply consistently everywhere (queues, seeders, CLI import commands).
Stop-word removal for cleaner slugs. For content sites where titles are conversational ("The Best Of The Best"), URLify::removeWords(['the', 'a', 'an', 'of']) produces shorter, prettier URLs. Register it alongside addChars in the provider.
Integration points that work well:
saving for generation, updating if the slug should track title changes (usually with a "keep existing on update" guard).alpha_dash and Rule::unique(...)->ignore($model), and normalize user-supplied slugs through URLify::filter() before saving.Static mutable state and Octane. addChars() and removeWords() mutate class-level static arrays for the life of the process. Under Octane (or any long-running worker), calling them inside a controller will leak between requests. Register them once in a provider, or make the calls idempotent and guard them with a booted flag.
The language argument must match a map file. Passing 'pt-BR' when only pt exists silently falls back to the default (English) map — you get no error, just wrong transliteration for ç/ã. Use the short code, and verify against the file names in src/URLify/maps/.
maxLength truncates, it does not word-boundary. The default 200-character cap cuts the string at the boundary and trims the trailing separator, which can leave a truncated word (...-translatio). If that matters, either validate length upstream or slice on your own word boundary before calling filter().
Uniqueness, casing, and emptiness. filter() returns lowercase and may return an empty string for inputs that are entirely non-transliterable (emoji, CJK-only titles without a language map that handles them). Always fall back:
$slug = Slug::make($title) ?: Str::random(8);
It is opinionated about what survives. Punctuation, symbols, and HTML-ish content are stripped/normalized aggressively. If your titles contain meaningful markup, sanitize first and confirm the output on a sample of real records rather than assuming.
mbstring is required. The library relies on mb_* functions for UTF-8 handling. It's on by default in most modern Laravel installs, but it is a real requirement in minimal Docker images — add ext-mbstring to your composer.json require block.
Comparison to Str::slug(). Laravel's built-in is fine for Latin-only projects and needs no dependency. URLify wins when you need (a) language-specific transliteration (German umlauts, Turkish dotted i, Nordic letters), (b) configurable stop-word removal, or (c) consistent output across many locales. If neither (a) nor (b) applies, Str::slug() is one fewer dependency.
Extension points are the maps, not the code. For most "wrong output" bugs, the fix is addChars() or a project-local map, not a fork. Keep those overrides in config/slugs.php so upgrades to the package do not clobber them.
Test the actual strings. Because the behavior is data-driven from the map files, unit-test representative inputs (your real product titles, your worst emoji case, your longest title) rather than trusting the docs. A SlugTest with a handful of assertSame cases catches map regressions on package updates quickly.
How can I help you explore Laravel packages today?