How to Create SEO-Optimized URLs from Strings in PHP — The Complete Guide

1893 views
create seo friendly url in php,seo optimized urls php,php slug generator,seo friendly string to url php,generate clean url in php,convert title to slug in php,php seo url function,seo best practices php urls,seo friendly blog urls php,php url optimization

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.

SEO-URL-Generation-Flow

Part 2 — The Production-Ready PHP Slug Generator

The original article’s 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 &amp; &nbsp; 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)

The original article’s 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 the original article mentions in one sentence and never solves. 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; }
}

SEO-URL-Benefits

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

The original article’s 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)

 

Frequently Asked Questions

+

What is an SEO-friendly URL in PHP?

An SEO-friendly URL is a clean, readable URL that contains meaningful keywords instead of special characters or unnecessary parameters. These URLs improve user experience and help search engines understand your page content.
+

Why should I convert strings into SEO-friendly URLs?

Converting strings into SEO-friendly URLs improves search engine optimization (SEO), increases click-through rates, makes URLs easier to share, and creates a better browsing experience for users.
+

How do I generate a URL slug in PHP?

You can generate a URL slug by converting the string to lowercase, removing special characters, replacing spaces with hyphens, and trimming extra hyphens. PHP functions like strtolower(), preg_replace(), and trim() are commonly used.
+

How do I remove special characters from a URL string in PHP?

Use regular expressions with preg_replace() to remove or replace special characters while keeping letters, numbers, and hyphens to create a clean URL slug.
+

Should SEO-friendly URLs be lowercase?

Yes. Using lowercase URLs prevents duplicate content issues caused by case-sensitive URLs and provides consistency across your website.
+

How do I replace spaces with hyphens in PHP?

You can replace one or more spaces using preg_replace() or str_replace() to convert them into hyphens, making the URL more search-engine friendly.
+

How do I handle duplicate SEO URL slugs?

Append a unique value such as an ID, timestamp, or incremental number (e.g., php-tutorial, php-tutorial-2) to ensure each URL remains unique.
+

Are SEO-friendly URLs important for WordPress and custom PHP websites?

Yes. Whether you're using WordPress, Laravel, CodeIgniter, or plain PHP, clean and descriptive URLs help improve SEO, usability, and website organization.
+

What are the best practices for creating SEO-friendly URLs?

Use lowercase letters. Replace spaces with hyphens. Remove special characters. Keep URLs short and descriptive. Include relevant keywords. Avoid unnecessary parameters. Ensure each URL is unique. Do not use stop words unless they add meaning.
+

Can I create multilingual SEO-friendly URLs in PHP?

Yes. You can use functions like iconv(), transliteration libraries, or custom character mappings to convert Unicode characters into SEO-friendly Latin equivalents while preserving readability.
Previous Article

How to Build a Production-Grade SEO-Friendly FAQ System in WordPress — The Complete Developer's Guide

Next Article

How to Build a Password Strength Checker in JavaScript & jQuery — The Complete Guide

View Comments (11)
  1. Your way of telling everything in this paragraph is in fact pleasant,
    all be capable of without difficulty understand it, Thanks a lot.

  2. 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!

  3. 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.

  4. 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.

  5. Hi there mates, its great piece of writing regarding cultureand entirely defined, keep it up all the time.

  6. It’s great that you are getting thoughts from this article as well as
    from our dialogue made at this time.

  7. 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

  8. Excellent, what a blog it is! This blog presents helpful data to us,
    keep it up.

  9. Hello, after reading this awesome article i am as well cheerful to share my experience here
    with friends.

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨