Why URL Slugs Are Critical for SEO and User Experience
Every URL on your website is a ranking signal. Google reads URLs to understand page content before it even crawls the page. A URL like:
❌ https://example.com/post.php?id=47&cat=3&lang=en
tells Google nothing about what the page contains, looks ugly in search results, and discourages clicks.
Compare that to:
✅ https://example.com/how-to-create-seo-optimized-urls-in-php
This URL tells Google, the user, and sharing platforms exactly what the page is about — before a single character of content is read.
What converting a string to a URL slug actually involves:
A URL slug is the URL-friendly version of a title, heading, or any string. The process sounds simple — make it lowercase and replace spaces with hyphens — but in production PHP applications, you encounter dozens of edge cases the basic tutorials never cover:
- Titles with accented characters (Café → cafe, José → jose)
- Titles in Hindi, Arabic, Chinese, Japanese — what do you do?
- Special characters: &, ©, ™, @, #, %, +, =
- Multiple spaces, tab characters, newlines
- Strings that become entirely empty after slug generation
- Duplicate slugs — two posts with the same title
- Slug length limits for databases and URLs
- Stop words — should “the”, “a”, “of” be removed?
- Emoji in titles (yes, this happens)
- Existing hyphens and underscores in the source string
- Framework-specific requirements (Laravel, CodeIgniter, Symfony)
- WordPress compatibility
This guide covers every single one of these scenarios with production-ready code you can drop directly into any PHP project.
Part 1 — Understanding What Makes a URL SEO-Friendly
Before writing a single line of PHP, understand the rules that define a good URL slug.
Google’s URL Best Practices (Official)
| Rule | Example | Why It Matters |
|---|---|---|
| Use lowercase only | my-blog-post not My-Blog-Post | Case sensitivity causes duplicate content |
| Use hyphens, not underscores | php-url-tips not php_url_tips | Google treats hyphens as word separators |
| Keep it short | Under 60 characters ideally | Truncated in SERPs beyond ~70 chars |
| Include the target keyword | seo-friendly-urls-php | Direct ranking signal |
| No special characters | No ?, #, &, % in slug | Browser and crawling issues |
| No unnecessary parameters | No ?page=1&sort=asc | Query strings dilute link equity |
| Static, not dynamic | /about-us not /page.php?id=5 | Indexability and shareability |
| No stop words (debated) | Remove “the”, “a”, “of” optionally | Shorter URLs, disputed SEO value |
The Anatomy of a Perfect URL
https://example.com/category/my-seo-optimised-page-title │ │ │ │ Protocol Domain Category Slug (this is what we generate)
The slug is the last segment — the part we generate from a title string. Everything before it is the site’s URL structure.

Part 2 — The Production-Ready PHP Slug Generator
Function handles happy-path ASCII strings. Here is the complete production version that handles every real-world input:
<?php
/**
* Production PHP Slug Generator
*
* Converts any string into a clean, SEO-friendly URL slug.
* Handles: ASCII, Unicode/UTF-8, accented chars, emoji,
* special symbols, edge cases, and length limits.
*
* @param string $string Input string (title, heading, etc.)
* @param string $separator Word separator character (default: '-')
* @param int $max_length Maximum slug length (0 = no limit)
* @param bool $remove_stop Remove common stop words
* @return string Clean URL slug
*/
function generate_slug(
string $string,
string $separator = '-',
int $max_length = 0,
bool $remove_stop = false
): string {
// ── Step 1: Validate separator ────────────────────────────────────────
// Only allow hyphen or underscore as separator
$separator = in_array($separator, ['-', '_'], true) ? $separator : '-';
// ── Step 2: Decode HTML entities ─────────────────────────────────────
// Input may come from database or form with & etc.
$string = html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// ── Step 3: Transliterate Unicode to ASCII ────────────────────────────
// Converts: é→e, ñ→n, ü→u, ç→c, 日→ri, etc.
// Falls back gracefully if iconv fails or isn't available
if (function_exists('iconv')) {
$transliterated = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $string);
if ($transliterated !== false && $transliterated !== '') {
$string = $transliterated;
}
}
// ── Step 4: Convert to lowercase ─────────────────────────────────────
$string = strtolower($string);
// ── Step 5: Handle common special characters before regex ────────────
$char_map = [
'&' => '-and-',
'@' => '-at-',
'+' => '-plus-',
'=' => '-equals-',
'<' => '',
'>' => '',
'%' => '-percent-',
'#' => '-hash-',
'/' => '-',
'\\' => '-',
'|' => '-',
'~' => '',
'`' => '',
'^' => '',
'{' => '',
'}' => '',
'[' => '',
']' => '',
'(' => '',
')' => '',
'*' => '',
'!' => '',
'?' => '',
',' => '',
'.' => '',
';' => '',
':' => '',
'"' => '',
"'" => '',
'"' => '',
'"' => '',
''' => '',
''' => '',
'…' => '',
'—' => '-',
'–' => '-',
'™' => '',
'®' => '',
'©' => '',
'°' => '',
'$' => '',
'£' => '',
'€' => '',
'¥' => '',
'₹' => '',
];
$string = strtr($string, $char_map);
// ── Step 6: Remove remaining non-alphanumeric characters ─────────────
// Keep only letters (a-z), numbers (0-9), separator, and spaces
$string = preg_replace('/[^a-z0-9\s' . preg_quote($separator, '/') . ']/', '', $string);
// ── Step 7: Optional — remove stop words ─────────────────────────────
if ($remove_stop) {
$string = remove_stop_words($string);
}
// ── Step 8: Replace all whitespace and consecutive separators ─────────
// Normalise multiple spaces, tabs, newlines → single separator
$string = preg_replace('/[\s' . preg_quote($separator, '/') . ']+/', $separator, $string);
// ── Step 9: Trim separator from both ends ─────────────────────────────
$string = trim($string, $separator);
// ── Step 10: Enforce maximum length ───────────────────────────────────
if ($max_length > 0 && strlen($string) > $max_length) {
$string = substr($string, 0, $max_length);
// Don't cut mid-word — trim to last separator
$last_sep = strrpos($string, $separator);
if ($last_sep !== false && $last_sep > ($max_length * 0.6)) {
$string = substr($string, 0, $last_sep);
}
$string = trim($string, $separator);
}
// ── Step 11: Fallback for empty result ────────────────────────────────
if ($string === '') {
$string = 'post-' . substr(md5(uniqid()), 0, 8);
}
return $string;
}
Running Every Edge Case Through It
<?php
// Test suite for generate_slug()
$test_cases = [
// Standard
'How to Create SEO Optimized URLs from String in PHP!'
=> 'how-to-create-seo-optimized-urls-from-string-in-php',
// Accented characters
'Café au Lait Recipe'
=> 'cafe-au-lait-recipe',
// Accented Eastern European
'Łódź City Guide'
=> 'lodz-city-guide',
// Special symbols
'Top 10 PHP Tips & Tricks for 2025'
=> 'top-10-php-tips-and-tricks-for-2025',
// Currencies and symbols
'Price: ₹999 for Premium Plan!'
=> 'price-999-for-premium-plan',
// Em dash and smart quotes
'"Hello World" — A Beginner\'s Guide'
=> 'hello-world-a-beginners-guide',
// Multiple spaces and tabs
"PHP URL Slugs Guide"
=> 'php-url-slugs-guide',
// Trademark and copyright
'WordPress™ Plugin Development © 2025'
=> 'wordpress-plugin-development-2025',
// Slashes
'HTML/CSS/JS Tutorial for Beginners'
=> 'html-css-js-tutorial-for-beginners',
// Already a slug (idempotent)
'already-a-clean-slug'
=> 'already-a-clean-slug',
// Numbers only
'2025'
=> '2025',
// Hyphenated words
'E-commerce vs In-store Shopping'
=> 'e-commerce-vs-in-store-shopping',
// Email in title
'Contact admin@example.com for help'
=> 'contact-adminexamplecom-for-help',
// Length limiting
'This is a very long title that should be truncated at a reasonable word boundary'
=> (function() {
return generate_slug(
'This is a very long title that should be truncated at a reasonable word boundary',
'-',
50
);
})(),
];
foreach ($test_cases as $input => $expected) {
$result = generate_slug($input);
$status = is_string($expected) ? ($result === $expected ? '✅' : '❌') : '🔵';
echo "$status Input: $input\n";
echo " Output: $result\n";
if (is_string($expected) && $result !== $expected) {
echo " Expected: $expected\n";
}
echo "\n";
}
Part 3 — Unicode Slug Generation (Multi-Language Support)
iconv approach works for Latin-based scripts (French, German, Spanish, Portuguese, Polish, Czech, etc.) but fails silently for non-Latin scripts like Hindi, Chinese, Japanese, or Arabic.
Here’s the full strategy for each language group:
Latin-Extended Languages (French, German, Spanish, etc.)
iconv with ASCII//TRANSLIT handles this perfectly:
<?php
// French
echo generate_slug("L'art de la programmation en PHP");
// → l-art-de-la-programmation-en-php
// German — ü→u, ö→o, ä→a, ß→ss
echo generate_slug("Über PHP 8 — Neue Funktionen");
// → uber-php-8-neue-funktionen
// Spanish
echo generate_slug("Cómo crear URLs amigables para SEO en PHP");
// → como-crear-urls-amigables-para-seo-en-php
// Portuguese
echo generate_slug("Programação PHP para Iniciantes");
// → programacao-php-para-iniciantes
// Polish
echo generate_slug("Jak tworzyć przyjazne adresy URL w PHP");
// → jak-tworzyc-przyjazne-adresy-url-w-php
Hindi / Devanagari Script
iconv cannot transliterate Devanagari to ASCII. You have three options:
<?php
/**
* Option 1: Transliteration map for Hindi (Devanagari → IAST/Latin)
* Practical for small controlled vocabularies
*/
function hindi_to_slug(string $string): string {
$devanagari_map = [
'अ' => 'a', 'आ' => 'aa', 'इ' => 'i', 'ई' => 'ee',
'उ' => 'u', 'ऊ' => 'oo', 'ए' => 'e', 'ओ' => 'o',
'क' => 'k', 'ख' => 'kh', 'ग' => 'g', 'घ' => 'gh',
'च' => 'ch', 'ज' => 'j', 'ट' => 't', 'ड' => 'd',
'त' => 't', 'द' => 'd', 'न' => 'n', 'प' => 'p',
'फ' => 'ph', 'ब' => 'b', 'भ' => 'bh', 'म' => 'm',
'य' => 'y', 'र' => 'r', 'ल' => 'l', 'व' => 'v',
'स' => 's', 'ह' => 'h', 'ं' => 'n', 'ा' => 'a',
'ि' => 'i', 'ी' => 'i', 'ु' => 'u', 'ू' => 'u',
'े' => 'e', 'ो' => 'o', '्' => '', ' ' => '-',
];
$result = strtr($string, $devanagari_map);
return generate_slug($result); // Clean up anything remaining
}
echo hindi_to_slug("PHP में URL कैसे बनाएं");
// → php-mn-url-kaise-bnaayn (approximate — full map needed for accuracy)
/**
* Option 2: URL-encode the Unicode string (preserves original, readable by browsers)
* Use when target audience primarily uses that language
*/
function unicode_slug(string $string): string {
// Normalize to NFC (composed form)
if (class_exists('Normalizer')) {
$string = \Normalizer::normalize($string, \Normalizer::FORM_C);
}
// Lowercase the string (mb_strtolower handles Unicode)
$string = mb_strtolower($string, 'UTF-8');
// Replace spaces with hyphens
$string = preg_replace('/\s+/u', '-', $string);
// Remove characters that are truly unsafe in URLs
// (but keep Unicode letters/numbers)
$string = preg_replace('/[^\p{L}\p{N}\-]/u', '', $string);
// Collapse multiple hyphens
$string = preg_replace('/-+/', '-', $string);
return trim($string, '-');
}
// Hindi example — produces URL-encoded slug
echo unicode_slug("PHP में URL बनाना");
// → php-में-url-बनाना
// In browser address bar: https://example.com/php-में-url-बनाना (shown correctly)
// Raw stored: php-%E0%A4%AE%E0%A5%87%E0%A4%82-url-%E0%A4%AC%E0%A4%A8%E0%A4%BE%E0%A4%A8%E0%A4%BE
/**
* Option 3: Use the cocur/slugify library (recommended for production)
* Handles 90+ languages with pre-built character maps
* Install: composer require cocur/slugify
*/
// require_once 'vendor/autoload.php';
// use Cocur\Slugify\Slugify;
// $slugify = new Slugify(['rulesets' => ['default', 'hindi']]);
// echo $slugify->slugify("PHP में URL कैसे बनाएं");
Arabic Script
<?php
/**
* Arabic → Latin transliteration
* Note: Arabic is RTL and has no direct Latin equivalents
* Best practice: use phonetic transliteration or English parallel slug
*/
function arabic_to_slug(string $string): string {
$arabic_map = [
'ا' => 'a', 'ب' => 'b', 'ت' => 't', 'ث' => 'th',
'ج' => 'j', 'ح' => 'h', 'خ' => 'kh', 'د' => 'd',
'ذ' => 'dh', 'ر' => 'r', 'ز' => 'z', 'س' => 's',
'ش' => 'sh', 'ص' => 's', 'ض' => 'd', 'ط' => 't',
'ظ' => 'z', 'ع' => 'a', 'غ' => 'gh', 'ف' => 'f',
'ق' => 'q', 'ك' => 'k', 'ل' => 'l', 'م' => 'm',
'ن' => 'n', 'ه' => 'h', 'و' => 'w', 'ي' => 'y',
'ة' => 'h', 'ى' => 'a', ' ' => '-',
];
return generate_slug(strtr($string, $arabic_map));
}
// Chinese, Japanese — use pinyin/romaji libraries or English parallel
// For CJK characters, iconv often produces '?' — best to provide an English slug manually
Complete Language Slug Function
<?php
/**
* Smart multilingual slug generator.
* Detects script type and applies appropriate transliteration.
*
* @param string $string Input in any language
* @param string $fallback Fallback slug if transliteration fails completely
*/
function smart_slug(string $string, string $fallback = ''): string {
// Detect if string contains only ASCII-compatible characters
$has_non_latin = preg_match('/[^\x00-\x7F]/', $string);
if (!$has_non_latin) {
// Pure ASCII/Latin — use standard generator
return generate_slug($string);
}
// Try iconv transliteration (covers most Latin-extended scripts)
if (function_exists('iconv')) {
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $string);
if ($ascii !== false && strlen(trim($ascii)) > 2) {
return generate_slug($ascii);
}
}
// Try mb_convert_encoding if iconv doesn't work
if (function_exists('mb_convert_encoding')) {
$encoded = mb_convert_encoding($string, 'ASCII', 'UTF-8');
$cleaned = generate_slug($encoded);
if ($cleaned !== '' && $cleaned !== 'post-' . substr(md5(uniqid()), 0, 8)) {
return $cleaned;
}
}
// Unicode slug (preserves non-Latin characters)
$unicode = unicode_slug($string);
if ($unicode !== '') {
return $unicode;
}
// Absolute fallback
return $fallback ?: 'post-' . substr(md5($string), 0, 8);
}
Part 4 — Stop Word Removal (Optional but Powerful)
Removing stop words makes URLs shorter and more keyword-dense:
With stops: how-to-create-a-seo-friendly-url-in-php (54 chars) Without stops: create-seo-friendly-url-php (30 chars)
<?php
/**
* Remove common English stop words from a string.
* Use BEFORE slug generation, not after.
*
* @param string $string Input string (should be lowercase already)
* @return string String with stop words removed
*/
function remove_stop_words(string $string): string {
// Common English stop words
// Source: Google, NLTK, and content SEO research
$stop_words = [
'a', 'an', 'the',
'and', 'or', 'but', 'nor', 'so', 'yet', 'for',
'at', 'by', 'for', 'in', 'of', 'on', 'to', 'up',
'as', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
'do', 'does', 'did', 'doing',
'have', 'has', 'had', 'having',
'it', 'its', 'itself',
'this', 'that', 'these', 'those',
'from', 'with', 'about', 'into', 'through', 'during',
'before', 'after', 'above', 'below',
'each', 'more', 'most', 'other', 'some', 'such',
'no', 'not', 'only', 'same', 'than', 'too', 'very',
'can', 'will', 'just',
'how', 'what', 'when', 'where', 'which', 'who', 'why',
];
$words = explode(' ', $string);
$filtered = array_filter($words, fn($w) => ! in_array($w, $stop_words, true));
return implode(' ', $filtered);
}
// Example
$title = "How to Create a SEO Friendly URL in PHP";
echo generate_slug($title, '-', 0, false);
// → how-to-create-a-seo-friendly-url-in-php
echo generate_slug($title, '-', 0, true);
// → create-seo-friendly-url-php
// ⚠️ WARNING: Use stop word removal carefully
// "How to" in tutorials is often the primary keyword intent
// "how-to-create-seo-url-php" is still better than "create-seo-url-php"
// because "how to" captures question-based search queries
Part 5 — Ensuring Slug Uniqueness in Your Database
This is the most critical part. In any real CMS or blog platform, two posts can have the same title. You need guaranteed unique slugs.
Pattern: Append Incrementing Number
<?php
/**
* Generate a unique slug by checking the database for conflicts.
*
* @param string $pdo Active PDO connection
* @param string $title The post title
* @param string $table Database table name
* @param string $slug_col Column storing the slug
* @param int $exclude_id Exclude a specific row ID (for updates)
* @return string Unique slug
*/
function generate_unique_slug(
PDO $pdo,
string $title,
string $table = 'posts',
string $slug_col = 'slug',
int $exclude_id = 0
): string {
$base_slug = generate_slug($title);
if (empty($base_slug)) {
$base_slug = 'post';
}
$slug = $base_slug;
$counter = 0;
$max_attempts = 100; // Prevent infinite loops
while ($counter < $max_attempts) {
// Build query — exclude current post ID when updating
$sql = "SELECT COUNT(*) FROM `{$table}` WHERE `{$slug_col}` = :slug";
$params = [':slug' => $slug];
if ($exclude_id > 0) {
$sql .= " AND id != :exclude_id";
$params[':exclude_id'] = $exclude_id;
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$count = (int) $stmt->fetchColumn();
if ($count === 0) {
break; // Slug is unique — stop here
}
// Slug exists — append counter
$counter++;
$slug = $base_slug . '-' . $counter;
}
return $slug;
}
// ── Usage: Creating a new post ─────────────────────────────────────────────
$pdo = new PDO('mysql:host=localhost;dbname=mysite;charset=utf8mb4', 'user', 'pass');
$title = "Top 10 PHP Tips for Developers";
$slug = generate_unique_slug($pdo, $title, 'posts');
$stmt = $pdo->prepare(
"INSERT INTO posts (title, slug, content, created_at)
VALUES (:title, :slug, :content, NOW())"
);
$stmt->execute([
':title' => $title,
':slug' => $slug,
':content' => $content,
]);
echo "Post URL: https://example.com/{$slug}";
// First post: https://example.com/top-10-php-tips-for-developers
// Second same: https://example.com/top-10-php-tips-for-developers-1
// Third same: https://example.com/top-10-php-tips-for-developers-2
// ── Usage: Updating an existing post ──────────────────────────────────────
$post_id = 42;
$new_title = "Top 10 PHP Tips for Developers 2025";
$new_slug = generate_unique_slug($pdo, $new_title, 'posts', 'slug', $post_id);
$stmt = $pdo->prepare("UPDATE posts SET title = :title, slug = :slug WHERE id = :id");
$stmt->execute([':title' => $new_title, ':slug' => $new_slug, ':id' => $post_id]);
Database Schema for Slugs
-- Always add a UNIQUE INDEX on the slug column
-- and keep it VARCHAR(255) or less for index efficiency
CREATE TABLE posts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(500) NOT NULL,
slug VARCHAR(255) NOT NULL,
content LONGTEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_slug (slug), -- Enforce uniqueness at DB level
KEY idx_slug (slug), -- Index for fast slug lookups
FULLTEXT KEY ft_title (title) -- Optional: for search
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- MySQL slug column best practices:
-- utf8mb4 charset handles emoji and all Unicode
-- VARCHAR(255) max because MySQL UNIQUE INDEX has a 767 byte limit
-- utf8mb4 uses 4 bytes per char, so 255 × 4 = 1020 bytes (within InnoDB 3072 limit)
High-Concurrency Safe Slug (Atomic Check-Insert)
For high-traffic sites, the check-then-insert pattern has a race condition. Two concurrent requests might both check, both find the slug is unique, and both try to insert — creating a duplicate key error. Handle this properly:
<?php
/**
* Atomic slug insertion — race condition safe.
* Uses INSERT ... ON DUPLICATE KEY UPDATE to handle concurrent requests.
*/
function insert_post_atomic_slug(PDO $pdo, string $title, string $content): array {
$base_slug = generate_slug($title);
$counter = 0;
$max_tries = 10;
while ($counter < $max_tries) {
$slug = $counter === 0 ? $base_slug : $base_slug . '-' . $counter;
try {
$stmt = $pdo->prepare(
"INSERT INTO posts (title, slug, content)
VALUES (:title, :slug, :content)"
);
$stmt->execute([
':title' => $title,
':slug' => $slug,
':content' => $content,
]);
return ['success' => true, 'id' => (int) $pdo->lastInsertId(), 'slug' => $slug];
} catch (\PDOException $e) {
// Error 23000 = SQLSTATE for integrity constraint violation (duplicate key)
if ($e->getCode() === '23000') {
$counter++;
continue;
}
throw $e; // Re-throw non-duplicate errors
}
}
throw new \RuntimeException("Could not generate a unique slug after {$max_tries} attempts.");
}
Part 6 — Complete Slug Class (OOP, Production-Ready)
For larger projects, encapsulate slug logic in a proper class:
<?php
/**
* SlugGenerator Class
*
* OOP wrapper for slug generation with configuration,
* database uniqueness checking, and caching.
*/
class SlugGenerator {
private PDO $pdo;
private array $config;
private array $char_map;
public function __construct(PDO $pdo, array $config = []) {
$this->pdo = $pdo;
$this->config = array_merge([
'separator' => '-',
'max_length' => 200,
'remove_stop_words' => false,
'table' => 'posts',
'slug_column' => 'slug',
'transliterate' => true,
'lowercase' => true,
'allow_unicode' => false, // Set true for CJK/Arabic URLs
'fallback_prefix' => 'post',
], $config);
$this->char_map = $this->build_char_map();
}
/**
* Generate a slug from a string.
*/
public function make(string $input): string {
if ($input === '') return $this->fallback_slug();
$slug = $input;
$slug = html_entity_decode($slug, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($this->config['transliterate']) {
$slug = $this->transliterate($slug);
}
if ($this->config['lowercase']) {
$slug = strtolower($slug);
}
$slug = strtr($slug, $this->char_map);
if ($this->config['remove_stop_words']) {
$slug = $this->strip_stop_words($slug);
}
$sep = preg_quote($this->config['separator'], '/');
$slug = preg_replace('/[^a-z0-9\s' . $sep . ']/i', '', $slug);
$slug = preg_replace('/[\s' . $sep . ']+/', $this->config['separator'], $slug);
$slug = trim($slug, $this->config['separator']);
if ($this->config['max_length'] > 0 && strlen($slug) > $this->config['max_length']) {
$slug = $this->trim_to_length($slug, $this->config['max_length']);
}
return $slug ?: $this->fallback_slug();
}
/**
* Generate a unique slug (with DB uniqueness check).
*/
public function make_unique(string $input, int $exclude_id = 0): string {
$base = $this->make($input);
$slug = $base;
$counter = 0;
do {
if ($counter > 0) {
$slug = $base . $this->config['separator'] . $counter;
}
$sql = "SELECT COUNT(*) FROM `{$this->config['table']}`
WHERE `{$this->config['slug_column']}` = :slug"
. ($exclude_id > 0 ? ' AND id != :eid' : '');
$params = [':slug' => $slug];
if ($exclude_id > 0) $params[':eid'] = $exclude_id;
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$count = (int) $stmt->fetchColumn();
$counter++;
} while ($count > 0 && $counter < 100);
return $slug;
}
/**
* Validate whether a given string is a valid slug.
*/
public function is_valid(string $slug): bool {
return (bool) preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $slug);
}
/**
* Check if a slug exists in the database.
*/
public function exists(string $slug, int $exclude_id = 0): bool {
$sql = "SELECT COUNT(*) FROM `{$this->config['table']}`
WHERE `{$this->config['slug_column']}` = :slug"
. ($exclude_id > 0 ? ' AND id != :eid' : '');
$params = [':slug' => $slug];
if ($exclude_id > 0) $params[':eid'] = $exclude_id;
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn() > 0;
}
private function transliterate(string $string): string {
if (function_exists('iconv')) {
$result = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $string);
if ($result !== false && strlen(trim($result)) > 0) {
return $result;
}
}
return $string;
}
private function trim_to_length(string $slug, int $max): string {
$slug = substr($slug, 0, $max);
$sep = $this->config['separator'];
$pos = strrpos($slug, $sep);
if ($pos !== false && $pos > ($max * 0.6)) {
$slug = substr($slug, 0, $pos);
}
return trim($slug, $sep);
}
private function fallback_slug(): string {
return $this->config['fallback_prefix'] . '-' . substr(md5(uniqid()), 0, 8);
}
private function strip_stop_words(string $string): string {
$stops = ['a','an','the','and','or','but','in','of','to','for','is','at','by'];
$words = explode(' ', $string);
$result = array_filter($words, fn($w) => !in_array($w, $stops, true));
return implode(' ', $result);
}
private function build_char_map(): array {
return [
'&' => '-and-', '@' => '-at-', '+' => '-plus-',
'=' => '-equals-', '%' => '', '#' => '', '/' => '-',
'\\' => '-', '|' => '-', '—' => '-', '–' => '-',
'"' => '', "'" => '', '"' => '', '"' => '',
'™' => '', '®' => '', '©' => '', '!' => '',
'?' => '', ',' => '', '.' => '', ';' => '',
':' => '', '(' => '', ')' => '', '[' => '',
']' => '', '{' => '', '}' => '', '<' => '',
'>' => '', '~' => '', '`' => '', '^' => '',
'$' => '', '£' => '', '€' => '', '¥' => '',
'₹' => '', '°' => '', '*' => '',
];
}
}
// ── Usage ──────────────────────────────────────────────────────────────────
$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8mb4', 'user', 'pass');
$gen = new SlugGenerator($pdo, [
'table' => 'articles',
'slug_column' => 'url_slug',
'max_length' => 100,
'remove_stop_words' => false,
]);
// Simple slug
echo $gen->make("Top 10 PHP Tips & Tricks for 2025!");
// → top-10-php-tips-and-tricks-for-2025
// Unique slug (DB checked)
echo $gen->make_unique("Top 10 PHP Tips & Tricks for 2025!");
// → top-10-php-tips-and-tricks-for-2025 (if no conflict)
// → top-10-php-tips-and-tricks-for-2025-1 (if conflict)
// Validate
var_dump($gen->is_valid('hello-world')); // bool(true)
var_dump($gen->is_valid('Hello World')); // bool(false)
var_dump($gen->is_valid('hello--world')); // bool(false)
var_dump($gen->is_valid('-hello-world')); // bool(false)
Part 7 — URL Routing: Making Slugs Work on Your Server
Generating slugs is half the job. You also need your web server to route /my-post-slug to the correct PHP file.
Apache — .htaccess Rewrite Rules
# Place in your .htaccess at the project root
Options -Indexes
RewriteEngine On
# Force HTTPS (recommended)
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Force non-www (choose one: www or non-www)
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [L,R=301]
# Remove trailing slash (except for directories)
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Route all requests through index.php
# (for custom CMS, blog, or any PHP application)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?slug=$1 [QSA,L]
# ── SPECIFIC ROUTE PATTERNS ────────────────────────────────────────────────
# Blog post: /post/my-slug
RewriteRule ^post/([a-z0-9\-]+)/?$ single-post.php?slug=$1 [QSA,L]
# Category: /category/my-category
RewriteRule ^category/([a-z0-9\-]+)/?$ category.php?cat=$1 [QSA,L]
# User profile: /user/username
RewriteRule ^user/([a-z0-9\-_]+)/?$ profile.php?user=$1 [QSA,L]
# Paginated: /blog/page/2
RewriteRule ^blog/page/([0-9]+)/?$ blog.php?page=$1 [QSA,L]
# Nested category/post: /category/slug
RewriteRule ^([a-z0-9\-]+)/([a-z0-9\-]+)/?$ post.php?cat=$1&slug=$2 [QSA,L]
Nginx — Server Block Configuration
server {
listen 80;
listen 443 ssl;
server_name example.com www.example.com;
root /var/www/html;
index index.php;
# Redirect www to non-www
if ($host ~* ^www\.(.+)$) {
return 301 https://$1$request_uri;
}
# Handle PHP files
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Main routing — try file, then directory, then slug router
location / {
try_files $uri $uri/ /index.php?slug=$uri&$args;
}
# Specific routes
location ~ ^/post/([a-z0-9\-]+)/?$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root/single-post.php;
fastcgi_param QUERY_STRING slug=$1;
include fastcgi_params;
}
# Deny access to hidden files
location ~ /\. { deny all; }
}
PHP Router (index.php)
<?php
/**
* Simple PHP Slug Router
* Handles slug-based URLs from Apache/Nginx rewrite rules.
*/
// Get the slug from the rewritten URL
$uri = trim($_SERVER['REQUEST_URI'], '/');
$slug = trim($_GET['slug'] ?? $uri, '/');
// Strip query string from slug
if (($qpos = strpos($slug, '?')) !== false) {
$slug = substr($slug, 0, $qpos);
}
// Validate slug format (only allow valid slug characters)
if (!preg_match('/^[a-z0-9\-\/]+$/', $slug)) {
http_response_code(400);
exit('Invalid URL');
}
// Database connection
$pdo = new PDO('mysql:host=localhost;dbname=blog;charset=utf8mb4', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// Route matching
$routes = [
// Pattern → Handler
'/^post\/([a-z0-9\-]+)$/' => 'render_single_post',
'/^category\/([a-z0-9\-]+)$/' => 'render_category',
'/^([a-z0-9\-]+)$/' => 'render_page',
];
$matched = false;
foreach ($routes as $pattern => $handler) {
if (preg_match($pattern, $slug, $matches)) {
$matched = true;
call_user_func($handler, $pdo, $matches);
break;
}
}
if (!$matched) {
http_response_code(404);
include 'templates/404.php';
}
// ── Route Handlers ──────────────────────────────────────────────────────────
function render_single_post(PDO $pdo, array $matches): void {
$slug = $matches[1];
$stmt = $pdo->prepare(
"SELECT p.*, c.name AS category_name
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id
WHERE p.slug = :slug AND p.status = 'published'"
);
$stmt->execute([':slug' => $slug]);
$post = $stmt->fetch();
if (!$post) {
// Try slug redirect (if slug was changed)
redirect_old_slug($pdo, $slug);
http_response_code(404);
include 'templates/404.php';
return;
}
include 'templates/single-post.php';
}
function render_category(PDO $pdo, array $matches): void {
$slug = $matches[1];
$stmt = $pdo->prepare("SELECT * FROM categories WHERE slug = :slug");
$stmt->execute([':slug' => $slug]);
$category = $stmt->fetch();
if (!$category) {
http_response_code(404);
include 'templates/404.php';
return;
}
$posts_stmt = $pdo->prepare(
"SELECT * FROM posts WHERE category_id = :cat_id AND status = 'published'
ORDER BY created_at DESC LIMIT 20"
);
$posts_stmt->execute([':cat_id' => $category['id']]);
$posts = $posts_stmt->fetchAll();
include 'templates/category.php';
}
function render_page(PDO $pdo, array $matches): void {
$slug = $matches[1];
$stmt = $pdo->prepare("SELECT * FROM pages WHERE slug = :slug AND status = 'published'");
$stmt->execute([':slug' => $slug]);
$page = $stmt->fetch();
if (!$page) {
http_response_code(404);
include 'templates/404.php';
return;
}
include 'templates/page.php';
}
function redirect_old_slug(PDO $pdo, string $old_slug): void {
// Check slug redirect table (for when slugs change)
$stmt = $pdo->prepare("SELECT new_slug FROM slug_redirects WHERE old_slug = :old LIMIT 1");
$stmt->execute([':old' => $old_slug]);
$redirect = $stmt->fetch();
if ($redirect) {
header('Location: /' . $redirect['new_slug'], true, 301);
exit;
}
}
Part 8 — Slug Redirects: Handling Changed URLs Without Losing SEO
When you rename a post and its slug changes, every inbound link and indexed URL becomes a 404. You need a redirect system.
-- Slug redirect table
CREATE TABLE slug_redirects (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
old_slug VARCHAR(255) NOT NULL,
new_slug VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_old_slug (old_slug),
KEY idx_new_slug (new_slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
/**
* When updating a post's slug, save the old slug as a redirect.
*/
function update_post_slug(PDO $pdo, int $post_id, string $new_title): string {
// Get current slug
$stmt = $pdo->prepare("SELECT slug, title FROM posts WHERE id = :id");
$stmt->execute([':id' => $post_id]);
$current = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$current) throw new \InvalidArgumentException("Post #{$post_id} not found.");
$old_slug = $current['slug'];
$new_slug = generate_unique_slug($pdo, $new_title, 'posts', 'slug', $post_id);
// No change needed
if ($old_slug === $new_slug) return $old_slug;
// Update the post
$update = $pdo->prepare("UPDATE posts SET title = :title, slug = :slug WHERE id = :id");
$update->execute([':title' => $new_title, ':slug' => $new_slug, ':id' => $post_id]);
// Save old slug as a 301 redirect
$redirect = $pdo->prepare(
"INSERT INTO slug_redirects (old_slug, new_slug)
VALUES (:old, :new)
ON DUPLICATE KEY UPDATE new_slug = :new2"
);
$redirect->execute([':old' => $old_slug, ':new' => $new_slug, ':new2' => $new_slug]);
return $new_slug;
}
Part 9 — Framework Integration
Laravel (Eloquent + Spatie Sluggable)
Laravel makes slug generation trivial with the Spatie package — but understanding what it does under the hood is valuable:
<?php
// Install: composer require spatie/laravel-sluggable
// In your Eloquent Model
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
class Post extends Model {
use HasSlug;
protected $fillable = ['title', 'slug', 'content'];
public function getSlugOptions(): SlugOptions {
return SlugOptions::create()
->generateSlugsFrom('title') // Source attribute
->saveSlugsTo('slug') // Target column
->slugsShouldBeNoLongerThan(200) // Max length
->usingSeparator('-') // Word separator
->doNotGenerateSlugsOnUpdate(); // Keep original slug on edit
}
// Custom route binding — find by slug, not ID
public function getRouteKeyName(): string {
return 'slug';
}
}
// In your routes/web.php
Route::get('/post/{post}', [PostController::class, 'show'])->name('post.show');
// Laravel automatically resolves Post model by slug via getRouteKeyName()
// Controller
class PostController extends Controller {
public function show(Post $post): View {
return view('post.show', compact('post'));
}
public function store(Request $request): RedirectResponse {
$validated = $request->validate([
'title' => 'required|string|max:500',
'content' => 'required|string',
]);
// Slug is generated automatically by the HasSlug trait
$post = Post::create($validated);
return redirect()->route('post.show', $post);
}
}
// Without the Spatie package — pure Laravel:
// In Post model observer:
class PostObserver {
public function creating(Post $post): void {
$post->slug = $this->unique_slug($post->title);
}
public function updating(Post $post): void {
// Only change slug if it wasn't manually set
if ($post->isDirty('title') && !$post->isDirty('slug')) {
$post->slug = $this->unique_slug($post->title, $post->id);
}
}
private function unique_slug(string $title, int $exclude = 0): string {
$base = Str::slug($title); // Laravel's built-in slugifier
$slug = $base;
$i = 1;
while (Post::where('slug', $slug)->when($exclude, fn($q) => $q->where('id', '!=', $exclude))->exists()) {
$slug = $base . '-' . $i++;
}
return $slug;
}
}
CodeIgniter 4
<?php
namespace App\Models;
use CodeIgniter\Model;
class PostModel extends Model {
protected $table = 'posts';
protected $useTimestamps = true;
protected $allowedFields = ['title', 'slug', 'content'];
/**
* Auto-generate slug on insert.
*/
protected function generateSlug(array $data): array {
if (!empty($data['data']['title'])) {
$slug = $this->createUniqueSlug($data['data']['title']);
$data['data']['slug'] = $slug;
}
return $data;
}
protected $beforeInsert = ['generateSlug'];
private function createUniqueSlug(string $title, int $exclude = 0): string {
helper('url'); // Load URL helper for url_title()
$base = strtolower(url_title($title, '-', true));
$slug = $base;
$i = 1;
while ($this->where('slug', $slug)
->when($exclude > 0, fn($q) => $q->where('id !=', $exclude))
->countAllResults() > 0) {
$slug = $base . '-' . $i++;
}
return $slug;
}
}
WordPress (Built-in Slug Functions)
<?php
/**
* WordPress has built-in slug functions you should know.
*/
// ── Core WordPress functions ───────────────────────────────────────────────
// sanitize_title() — converts a string to a slug
$slug = sanitize_title("How to Create SEO Optimized URLs in PHP!");
// → how-to-create-seo-optimized-urls-in-php
// sanitize_title_with_dashes() — more options
$slug = sanitize_title_with_dashes("My Post Title & More", '', 'save');
// → my-post-title-and-more
// wp_unique_post_slug() — makes slug unique in posts table
$unique_slug = wp_unique_post_slug(
'my-post-title', // desired slug
$post_id, // post ID (0 for new)
'publish', // post status
'post', // post type
0 // parent post ID
);
// ── Creating posts with auto-generated slugs ──────────────────────────────
$post_data = [
'post_title' => 'My New Blog Post',
'post_content'=> 'Content here...',
'post_status' => 'publish',
'post_type' => 'post',
// WordPress generates the slug from post_title automatically
// But you can also set it explicitly:
'post_name' => sanitize_title('My New Blog Post'),
];
$post_id = wp_insert_post($post_data);
// ── Custom post type slugs ────────────────────────────────────────────────
// When registering a CPT:
register_post_type('product', [
'rewrite' => [
'slug' => 'products', // Base URL: /products/post-slug
'with_front' => false,
'feeds' => true,
'pages' => true,
],
]);
// ── Programmatic slug generation for any CPT ─────────────────────────────
function my_generate_product_slug(string $title): string {
$slug = sanitize_title($title);
$slug = wp_unique_post_slug($slug, 0, 'publish', 'product', 0);
return $slug;
}
Part 10 — Using Composer Libraries (When to Reach for a Package)
For complex multilingual applications, a battle-tested library beats custom code:
# Install the most popular PHP slug library composer require cocur/slugify # Install if you need transliteration support composer require symfony/string
<?php
require_once 'vendor/autoload.php';
use Cocur\Slugify\Slugify;
use Symfony\Component\String\Slugger\AsciiSlugger;
// ── cocur/slugify ──────────────────────────────────────────────────────────
$slugify = new Slugify();
// Basic
echo $slugify->slugify('Hello World');
// → hello-world
// With custom separator
echo $slugify->slugify('Hello World', ['separator' => '_']);
// → hello_world
// With language-specific rules (70+ languages supported)
$slugify = new Slugify(['rulesets' => ['default', 'german']]);
echo $slugify->slugify('Öl für die Wiese');
// → oel-fuer-die-wiese (German-specific: ö→oe, ü→ue, ä→ae)
// Hindi
$slugify = new Slugify(['rulesets' => ['default', 'hindi']]);
echo $slugify->slugify('स्वागत है');
// → swagat-hai
// ── Symfony AsciiSlugger ──────────────────────────────────────────────────
$slugger = new AsciiSlugger();
// Basic
echo $slugger->slug('Hello World');
// → Hello-World (note: uppercase preserved!)
// Lowercase
echo strtolower($slugger->slug('Hello World'));
// → hello-world
// With locale
$slugger = new AsciiSlugger('de'); // German
echo strtolower($slugger->slug('Über den Wolken'));
// → ueber-den-wolken
// ── When to use a library vs custom code ──────────────────────────────────
/*
USE A LIBRARY when:
✅ Your app supports 3+ languages with non-Latin scripts
✅ You need language-specific transliteration (ö→oe in German, ö→o in generic)
✅ You're already using Composer
✅ You want automatic updates as Unicode standards evolve
USE CUSTOM CODE when:
✅ Single-language site (English or Latin-only)
✅ No Composer available (shared hosting without shell)
✅ You need very specific character mapping logic
✅ You want zero external dependencies
✅ Performance is critical (library has overhead)
*/
Part 11 — SEO Best Practices for PHP URL Slugs (Complete Reference)
Length Optimisation
Rule of thumb: under 60 characters Why: Google truncates titles at ~60 chars in SERPs — same for URLs Best practice: 3-5 words, keyword-focused ✅ /seo-friendly-urls-php (24 chars — ideal) ✅ /create-seo-url-from-string-php (34 chars — good) ⚠️ /how-to-create-seo-optimized-urls-from-string-in-php (54 chars — acceptable) ❌ /how-to-create-clean-seo-friendly-url-slug-from-string-title-in-php-tutorial (77 chars — too long)
Hyphens vs Underscores: The Definitive Answer
// Google officially treats hyphens as word separators // "seo-friendly-urls" → three separate words: seo, friendly, urls // Google treats underscores as character joiners // "seo_friendly_urls" → one word: seofriendlyurls // Result: hyphens always win for SEO $slug = generate_slug($title, '-'); // ✅ Correct $slug = generate_slug($title, '_'); // ❌ Avoid // Exception: if you have existing underscored URLs with high ranking, // don't change them — the SEO cost of changing URLs > the benefit
Stop Words: Data-Driven Decision
WITH "how-to": "how to create seo url php" → 110 searches/month (question-intent traffic) WITHOUT "how-to": "create seo url php" → 30 searches/month VERDICT: Keep "how to" — it captures the majority of the intent. REMOVE THESE stop words (minimal search intent value): a, an, the, and, or, for, in, of, to, at, by, is, are, was, were KEEP THESE "stop words" (actually have search value): how, what, why, when, where, which, who → question queries not, no, without → negative searches vs, versus → comparison searches best, top, free → modifier keywords
URL Canonicalization in PHP
<?php
/**
* Ensure canonical URLs are set correctly for slug-based pages.
* Prevents duplicate content from trailing slashes,
* uppercase variations, and parameter pollution.
*/
function canonical_url(string $slug, string $base_url = ''): string {
$base = rtrim($base_url ?: 'https://' . $_SERVER['HTTP_HOST'], '/');
// Ensure slug is clean
$slug = trim($slug, '/');
$slug = generate_slug($slug); // Re-sanitize
return $base . '/' . $slug;
}
// In your page template header:
$canonical = canonical_url($post['slug'], 'https://example.com');
echo '<link rel="canonical" href="' . htmlspecialchars($canonical) . '">';
// ── Redirect non-canonical to canonical ───────────────────────────────────
$request_uri = strtolower(trim($_SERVER['REQUEST_URI'], '/'));
$clean_slug = generate_slug($request_uri);
if ($request_uri !== $clean_slug) {
header('Location: /' . $clean_slug, true, 301);
exit;
}
Part 12 — Performance: Caching Generated Slugs
For high-traffic sites, regenerating slugs on every page load is wasteful. Cache them:
<?php
/**
* Cached Slug Lookup
* Stores slug→ID mappings in APCu or file cache to avoid DB queries.
*/
class SlugCache {
private const TTL = 3600; // Cache for 1 hour
private const PREFIX = 'slug_';
/**
* Get post ID from a slug (with caching).
*/
public static function get_post_id_by_slug(PDO $pdo, string $slug): ?int {
$cache_key = self::PREFIX . md5($slug);
// Try APCu first (fastest — in-memory)
if (function_exists('apcu_fetch')) {
$cached = apcu_fetch($cache_key, $success);
if ($success) return $cached === 'null' ? null : (int)$cached;
}
// Fallback: query the database
$stmt = $pdo->prepare(
"SELECT id FROM posts WHERE slug = :slug AND status = 'published' LIMIT 1"
);
$stmt->execute([':slug' => $slug]);
$id = $stmt->fetchColumn();
$result = $id !== false ? (int)$id : null;
// Store in cache
if (function_exists('apcu_store')) {
apcu_store($cache_key, $result ?? 'null', self::TTL);
}
return $result;
}
/**
* Invalidate cache for a slug (call after update/delete).
*/
public static function invalidate(string $slug): void {
$cache_key = self::PREFIX . md5($slug);
if (function_exists('apcu_delete')) {
apcu_delete($cache_key);
}
}
}
// ── Usage ──────────────────────────────────────────────────────────────────
$post_id = SlugCache::get_post_id_by_slug($pdo, 'my-post-slug');
if (!$post_id) {
http_response_code(404);
exit('Not found');
}
// After updating a post's slug:
SlugCache::invalidate('old-post-slug');
SlugCache::invalidate('new-post-slug');
Part 13 — Complete Test Suite
Test every edge case before shipping:
<?php
/**
* Comprehensive test suite for generate_slug()
* Run with: php test-slug.php
*/
$tests = [
// ── Basic conversion ───────────────────────────────────────────────────
['input' => 'Hello World', 'expected' => 'hello-world'],
['input' => 'Hello World', 'expected' => 'hello-world'],
['input' => ' Hello World ', 'expected' => 'hello-world'],
// ── Punctuation ────────────────────────────────────────────────────────
['input' => 'Hello, World!', 'expected' => 'hello-world'],
['input' => "It's a Test", 'expected' => 'its-a-test'],
['input' => 'Test: Part 1', 'expected' => 'test-part-1'],
['input' => 'A & B', 'expected' => 'a-and-b'],
['input' => 'PHP 8.2 Features', 'expected' => 'php-82-features'],
['input' => '100% Free', 'expected' => '100-free'],
['input' => '#1 PHP Tutorial', 'expected' => '1-php-tutorial'],
// ── Special symbols ────────────────────────────────────────────────────
['input' => 'Cost: $99', 'expected' => 'cost-99'],
['input' => 'Price: ₹999', 'expected' => 'price-999'],
['input' => 'PHP™ Developer Guide', 'expected' => 'php-developer-guide'],
['input' => '© 2025 Example', 'expected' => '2025-example'],
['input' => 'HTML/CSS Tutorial', 'expected' => 'html-css-tutorial'],
// ── Accented characters ────────────────────────────────────────────────
['input' => 'Café Parisien', 'expected' => 'cafe-parisien'],
['input' => 'Über PHP', 'expected' => 'uber-php'],
['input' => 'Ñoño en España', 'expected' => 'nono-en-espana'],
['input' => 'Ångström Unit', 'expected' => 'angstrom-unit'],
// ── Numbers ────────────────────────────────────────────────────────────
['input' => 'PHP 8.2', 'expected' => 'php-82'],
['input' => '10 Tips', 'expected' => '10-tips'],
['input' => '2025', 'expected' => '2025'],
// ── Hyphens in input ───────────────────────────────────────────────────
['input' => 'e-commerce guide', 'expected' => 'e-commerce-guide'],
['input' => 'already-a-slug', 'expected' => 'already-a-slug'],
['input' => 'double--hyphen', 'expected' => 'double-hyphen'],
['input' => '-leading-hyphen', 'expected' => 'leading-hyphen'],
['input' => 'trailing-hyphen-', 'expected' => 'trailing-hyphen'],
// ── Edge cases ─────────────────────────────────────────────────────────
['input' => '', 'expected_pattern' => '/^post-[a-f0-9]{8}$/'],
['input' => '!!!', 'expected_pattern' => '/^post-[a-f0-9]{8}$/'],
['input' => ' ', 'expected_pattern' => '/^post-[a-f0-9]{8}$/'],
['input' => str_repeat('a', 300), 'expected' => str_repeat('a', 200)], // length limit test
// ── Smart quotes and dashes ────────────────────────────────────────────
['input' => '"Hello World"', 'expected' => 'hello-world'],
['input' => 'A — B', 'expected' => 'a-b'],
['input' => 'A – B', 'expected' => 'a-b'],
];
$passed = 0;
$failed = 0;
foreach ($tests as $test) {
$result = generate_slug($test['input'], '-', 200);
if (isset($test['expected'])) {
$ok = ($result === $test['expected']);
} else {
$ok = (bool) preg_match($test['expected_pattern'], $result);
}
if ($ok) {
$passed++;
echo "✅ PASS: \"" . substr($test['input'], 0, 40) . "\" → \"{$result}\"\n";
} else {
$failed++;
$exp = $test['expected'] ?? $test['expected_pattern'];
echo "❌ FAIL: \"" . substr($test['input'], 0, 40) . "\"\n";
echo " Got: \"{$result}\"\n";
echo " Expected: \"{$exp}\"\n";
}
}
echo "\n============================\n";
echo "Results: {$passed} passed, {$failed} failed\n";
echo ($failed === 0 ? "🎉 All tests passed!\n" : "⚠️ Some tests failed.\n");
The Complete PHP Slug System
seoUrl() function is a good starting point for simple projects. For any serious PHP application — a blog, CMS, e-commerce site, or SaaS — you need everything covered in this guide:
The generate_slug() function handles 30+ edge cases that the basic version misses — special characters, accented letters, smart quotes, em dashes, currencies, and symbols. It produces clean, predictable output for any real-world input.
Unicode support covers the vast majority of European languages via iconv transliteration, with fallback strategies for Hindi, Arabic, and CJK scripts.
The generate_unique_slug() function and atomic insert pattern ensure you never create duplicate slugs in your database — even under concurrent traffic.
The SlugGenerator class wraps everything in a proper OOP interface with configuration, validation, and caching support.
The routing system shows how to actually make your slugs work on Apache and Nginx — because generating slugs is only half the job.
The redirect system protects your SEO investment when slugs change — something almost every tutorial forgets to include.
Framework integrations for Laravel, CodeIgniter, and WordPress show you how to apply these concepts in the specific environment you’re actually using.
The slug is a small part of your URL — but it’s the part Google, users, and search algorithms read first. Getting it right is one of the simplest, highest-leverage SEO improvements you can make to any PHP project.
Quick Reference Card
FUNCTION SIGNATURES ──────────────────────────────────────────────────────────────── generate_slug(string $string, string $sep = '-', int $max = 0, bool $stop = false): string generate_unique_slug(PDO $pdo, string $title, string $table = 'posts', string $col = 'slug', int $exclude = 0): string smart_slug(string $string, string $fallback = ''): string COMMON CONVERSIONS ──────────────────────────────────────────────────────────────── "Hello World!" → hello-world "Café & Bistro" → cafe-and-bistro "PHP 8.2 — What's New?" → php-82-whats-new "₹999 for 1 Year" → 999-for-1-year "HTML/CSS/JS Guide" → html-css-js-guide "E-commerce Tips & Tricks" → e-commerce-tips-and-tricks SEO RULES RECAP ──────────────────────────────────────────────────────────────── ✅ Always lowercase ✅ Hyphens as separators (never underscores) ✅ Maximum 60 characters ✅ Include primary keyword ✅ No special characters or encoding ✅ UNIQUE INDEX on slug column in database ✅ 301 redirect when slugs change ✅ Canonical <link> tag in <head> FRAMEWORK SHORTCUTS ──────────────────────────────────────────────────────────────── Laravel: Str::slug($title) or spatie/laravel-sluggable CodeIgniter: url_title($title, '-', true) WordPress: sanitize_title($title) Symfony: (new AsciiSlugger())->slug($title)
Hello, I want to subscribe for this blog to obtain most recent updates, therefore where can i do it please
help out.
Hello, Neat post. There is an issue along with your site in internet explorer, could check this?
IE nonetheless is the marketplace leader and a huge portion of people will miss
your great writing because of this problem.
Hi, constantly i used to check blog posts here
in the early hours in the break of day, since i enjoy to gain knowledge of more and more.
Hey there! I understand this is sort of off-topic but I had to ask.
Does managing a well-established website like yours require a massive amount work?
I am completely new to running a blog however I do write in my journal everyday.
I’d like to start a blog so I will be able to
share my experience and thoughts online. Please let me know if you have any kind
of recommendations or tips for brand new aspiring blog owners.
Thankyou!
bookmarked!!, I really like your site!
Great blog right here! Additionally your site rather
a lot up very fast! What host are you using? Can I am getting your associate
hyperlink for your host? I desire my site loaded
up as quickly as yours lol
Hi there, There’s no doubt that your website could possibly be having browser compatibility issues.
When I look at your blog in Safari, it looks fine however, if opening in I.E., it’s got some overlapping
issues. I simply wanted to give you a quick heads up!
Aside from that, great blog!
Do you mind if I quote a couple of your posts as long as I provide credit and sources back to your webpage?
My website is in the very same area oof interest as
yours and mmy visitors would truly benefit from some of the information you present here.
Please let me know if this okay with you. Appreciate it!
Heya just wanted to give you a quick heads up and let you know a
few of the pictures aren’t loading correctly. I’m not sure why
but I think its a linking issue. I’ve tried itt
in two different internet browsers and both show
the same results.
It’s appropriate time to make some plans for the future and it’s
time to be happy. I have read this post and if I could I want to suggest you few interesting things or suggestions.
Maybe you can write next articles referring to this article.
I desire to read even more things about it!
It’s the best time to make some plans for the long run and
it’s time to be happy. I’ve learn this put up and if I may just I
wish to counsel you some fascinating issues or tips. Maybe you could write
next articles regarding this article. I wish to read more issues
about it!
You’ve made some decent points there. I looked on the web for more information about the
issue and found most individuals will go along with your views on this website.
Hello would you min sharing which blog platform you’re working with?
I’m going to start my own blog soon but I’m having a difficult time deciding between BlogEngine/Wordpress/B2evolution and Drupal.
The reason I ask is because your design seems different then most blogs and I’m looking for something unique.
P.S My apologies for getting off-topic but I had to ask!
Hi there, I discovered your web site by way of Google at the same
time as searching for a comparable topic, your web site got here up, it aappears to be like great.
I’ve bookmarked it in my google bookmarks.
Hello there, just became aware of your weblog through Google, and found that it’s really informative.
I am gonna watch out for brussels.I’ll be grateful in case you proceed thi
in future. Lots of folks can be benefited out of your writing.
Cheers!
Hey there this is somewhat of off topic but I was wondering if
blogs use WYSIWYG editors or if you have to manually code with HTML.
I’m starting a blog soon but have no coding expertise so I wanted to
get advice from someone with experience. Any help would be
greatly appreciated!
If you are goinng for best contents like me, just visit this web
page daily because it gives feature contents, thanks
Today, I went to the beachfront with my children. I found a sea shell and gave it to my
4 year old daughter and said “You can hear the ocean if you put this to your ear.” She put the
shell to her ear and screamed. There was a hermit crab inside and it pinched her ear.
She never wants to go back! LoL I know this is entirely off topic but I had
to tell someone!
Wonderful blog! Do you have any suggestions for aspiring writers?
I’m hoping to start my own website soon but I’m a little lost on everything.
Would you propose starting with a free platform like WordPress or go for
a paid option? There are so many options out there that I’m completely
confused .. Any ideas? Appreciate it!
It’s wonderful that you are getting thoughts from this piece of writing as well as from
our dialogue made at this place.
I am sure this piece of writing has touched all the internet
people, its really really fastidious piece of writing
on building up new website.
Your way of telling everything in this paragraph is in fact pleasant,
all be capable of without difficulty understand it, Thanks a lot.
Hey there! I realize this is sort of off-topic however I had to ask.
Does building a well-established website such as yours require a massive amount work?
I’m completely new to writing a blog however I do write in my diary daily.
I’d like to start a blog so I can easily share my
personal experience and views online. Please let me know if
you have any ideas or tips for brand new aspiring blog owners.
Appreciate it!
An outstanding share! I’ve just forwarded this onto a friend
who has been conducting a little homework on this.
And he actually bought me dinner due to the fact that I found it for him…
lol. So let me reword this…. Thanks for the meal!! But yeah, thanks for spending some time to talk
about this matter here on your website.
Nice post. I learn something new and challenging
on blogs I stumbleupon everyday. It will always be interesting to read
through articles from other writers and practice something from
other web sites.
Hi there mates, its great piece of writing regarding cultureand entirely defined, keep it up all the time.
Oh my goodness! Impressive article dude! Thank you, However I am going through issues with your RSS.
I don’t understand the reason why I cannot subscribe to it.
Is there anybody else getting similar RSS issues?
Anyone that knows the solution can you kindly respond?
Thanks!!
Your style is unique in comparison to other folks I have read stuff from.
I appreciate you for posting when you’ve got the opportunity, Guess I will
just book mark this site.
Aw, this was an incredibly good post. Taking the time and actual
effort to generate a superb article… but what can I say… I procrastinate a whole lot and don’t manage to get nearly anything
done.
My brother suggested I might like this web site. He was entirely right.
This post actually made my day. You cann’t imagine just how much time
I had spent for this information! Thanks!
Appreciation to my father who stated to me on the topic of this blog, this website is genuinely remarkable.
Hi there I am so delighted I found your web site, I really found
you by accident, while I was searching on Aol for something else, Regardless I am here now and would just like to say thank you for a tremendous post and a all round exciting blog (I also love the
theme/design), I don’t have time to read through it all at the minute but I have saved it and also included
your RSS feeds, so when I have time I will be back
to read much more, Please do keep up the great work.
Hmm is anyone else having problems with the images
on this blog loading? I’m trying to figure out
if its a problem on my end or if it’s the blog. Any feed-back would be greatly appreciated.
hey there and thank you for your information –
I’ve definitely picked up something new from right here.
I did however expertise several technical points using
this web site, since I experienced to reload the website many times previous to I could get it
to load properly. I had been wondering if your hosting is OK?
Not that I’m complaining, but sluggish loading instances times
will very frequently affect your placement in google and could damage your high-quality score
if advertising and marketing with Adwords. Anyway I am adding this RSS
to my email and could look out for much more of
your respective intriguing content. Make sure you update this again very soon.
It’s great that you are getting thoughts from this article as well as
from our dialogue made at this time.
Neat blog! Is your theme custom made or did you download it from somewhere?
A theme like yours with a few simple adjustements would really make my blog shine.
Please let me know where you got your design. Cheers
Excellent, what a blog it is! This blog presents helpful data to us,
keep it up.
Touche. Sound arguments. Keep up the amazing effort.
Keep on writing, great job!
Hello, after reading this awesome article i am as well cheerful to share my experience here
with friends.
We stumbled over here from a different web page and thought I should check things out.
I like what I see so now i am following you.
Look forward to going over your web page again.
I know this if offf topic but I’m loking into starting
my own blog and was curious what all is required to get setup?
I’m assuming having a blog like yours would cost a pretty penny?
I’m not very internet smasrt so I’m not 100% sure.
Any tips or advice would be greatly appreciated.
Appreciate it
Thank you for some other informative web site. Where
else may I am getting that type of info written in such an ideal manner?
I have a mission that I am just now working on, and I’ve been on the look out for such info.
Excellent beat ! I wish to apprentice while you amend your site,
how could i subscribe for a blog site? The account helped me a acceptable deal.
I had been a little bit acquainted of this your broadcast provided bright clear concept
Thanks for ones marvelous posting! I seriously enjoyed reading it, you’re a great author.
I will make certain to bookmark your blog and may come back from now on. I want to
encourage continue your great job, have a nice holiday weekend!
Someone essentially assist to make critically posts I would state.
That is the first time I frequented your web page and
up to now? I surprised with the analysis you made to create this actual submit
incredible. Fantastic job!
You actually make it seem really easy together with
your presentation however I in finding this matter to be really one thing which I think I would never understand.
It kind of feels too complicated and extremely large for
me. I am having a look ahead on your subsequent put up, I’ll attempt
to get the grasp of it!
This web site really has all the information I wanted about this subject and didn’t know who to ask.
Oh my goodness! Amazing article dude! Thank you, However I am having troubles with your RSS.
I don’t understand why I can’t join it. Is there anyone else having similar RSS
issues? Anyone who knows the solution will you kindly respond?
Thanks!!
Tremendous things here. I am very glad to see your post. Thanks a lot
and I’m taking a look forward to contact you. Will you please drop me
a mail?
Hi to all, as I am really eager of reading
this website’s post to be updated on a regular basis.
It includes good data.
I do not even know how I ended up here, but I
thought this post was good. I do not know who you are but
definitely you are going to a famous blogger if you are not already 😉 Cheers!
Every weekend i used to visit this web page, because i wish for enjoyment, for the reason that this this web page conations truly fastidious
funny information too.
You have made some good points there. I looked
on the internet for more info about the issue and found most people will go
along with your views on this site.
My brother suggested I may like this blog. He used to be entirely right.
This submit truly made my day. You can not consider simply
how much time I had spent for this info! Thank you!
Just wish to say your article is as amazing. The clearness in your post is just spectacular and i can assume you are an expert on this subject.
Fine with your permission allow me to grab your RSS feed to keep updated
with forthcoming post. Thanks a million and please carry on the rewarding work.
Valuable info. Fortunate me I discovered your web site accidentally, and I’m shocked why this
twist of fate didn’t came about in advance!
I bookmarked it.
Awesome post.
Write more, thats all I have to say. Literally, it seems as though you relied on the video to make your point.
You obviously know what youre talking about, why waste your intelligence on just posting videos
to your blog when you could be giving us something informative to read?
It’s really a nice and useful piece of info. I’m happy that you simply shared this helpful info with us.
Please keep us informed like this. Thanks for sharing.
Nice post. I learn something new and challenging on blogs I stumbleupon everyday.
It’s always useful to read through articles from other authors
and practice something from their websites.
Great goods from you, man. I’ve understand your stuff previous to and you’re just
extremely magnificent. I actually like what you have acquired here, certainly like what
you are stating and the way in which you say it.
You make it entertaining and you still take care of to keep it sensible.
I can’t wait to read far more from you. This is actually a great website.
Way cool! Some extremely valid points! I appreciate you writing this write-up and also the rest of the website is really good.
Great delivery. Solid arguments. Keep up the amazing
work.