Skip to content

How-to: Slug URL Management with History ​

FT reference: FT339 (NENE2-FT/sluglog) — Auto-generated slugs from titles, collision counter, slug history for old-slug 301 redirects, explicit slug override, vulnerability assessment, 17 tests / 50+ assertions PASS.

This guide shows how to generate clean URL slugs from content titles, handle collisions with sequential suffixes, preserve old slugs in a history table for permanent redirects, and prevent common attack vectors.

Schema ​

sql
CREATE TABLE articles (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL,
    slug       TEXT    NOT NULL UNIQUE,
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

CREATE TABLE slug_history (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    article_id INTEGER NOT NULL REFERENCES articles(id),
    old_slug   TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

Endpoints ​

MethodPathDescription
POST/articlesCreate article (auto-slug from title)
PUT/articles/{id}Update article (slug regenerated on title change)
GET/articles/by-slug/{slug}Get by current or old slug
GET/articles/{id}/slug-historyList slug history

Slug Generation ​

SlugHelper::fromTitle() ​

php
SlugHelper::fromTitle('Hello World')          // → "hello-world"
SlugHelper::fromTitle('PHP 8.4: New Features!') // → "php-8-4-new-features"
SlugHelper::fromTitle('  --Hello--  ')        // → "hello"
SlugHelper::fromTitle('')                     // → "untitled"
SlugHelper::fromTitle('---')                  // → "untitled"

Rules:

  1. Lowercase everything
  2. Replace non-alphanumeric characters with -
  3. Collapse consecutive hyphens
  4. Trim leading/trailing hyphens
  5. Return "untitled" if result is empty
php
public static function fromTitle(string $title): string
{
    $slug = strtolower($title);
    $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
    $slug = trim($slug, '-');
    return $slug !== '' ? $slug : 'untitled';
}

Collision Resolution ​

php
POST /articles  {"title": "Hello", "body": "..."}  → 201  {"slug": "hello"}
POST /articles  {"title": "Hello", "body": "..."}  → 201  {"slug": "hello-2"}
POST /articles  {"title": "Hello", "body": "..."}  → 201  {"slug": "hello-3"}
php
public static function makeUnique(string $base, callable $isTaken): string
{
    if (!$isTaken($base)) {
        return $base;
    }

    $i = 2;
    while ($isTaken("{$base}-{$i}")) {
        $i++;
    }

    return "{$base}-{$i}";
}

$isTaken is a DB lookup callback: fn(string $s): bool => (bool) $repo->findBySlug($s).

Create Article ​

php
POST /articles
{"title": "My First Post", "body": "Content here."}
→ 201
{
  "id": 1,
  "title": "My First Post",
  "slug": "my-first-post",
  "body": "...",
  "created_at": "..."
}

Update Article ​

php
PUT /articles/1
{"title": "New Title", "body": "Updated content."}
→ 200  {"slug": "new-title", ...}

When the title changes, the new slug is derived and the old slug is saved to slug_history.

php
// Same title — slug unchanged, no history entry
PUT /articles/1  {"title": "New Title", "body": "Different body."}
→ 200  {"slug": "new-title"}  // same slug

// Explicit slug override
PUT /articles/1  {"title": "New Title", "body": "Body.", "slug": "custom-url-here"}
→ 200  {"slug": "custom-url-here"}

// Collision on update — auto-resolved
// (if "popular" exists, renames to "popular-2")
PUT /articles/2  {"title": "Popular", "body": "Body."}
→ 200  {"slug": "popular-2"}

// Unknown article
PUT /articles/9999  {"title": "X", "body": "Y"}
→ 404

Get by Slug ​

php
// Current slug → 200
GET /articles/by-slug/new-title
→ 200  {"id": 1, "slug": "new-title", "title": "New Title", ...}

// Old slug → 301 redirect
GET /articles/by-slug/my-first-post
→ 301
{
  "redirect": true,
  "canonical_slug": "new-title"
}

// Unknown → 404
GET /articles/by-slug/does-not-exist
→ 404

301 responses tell crawlers/clients to update their links to the canonical slug.

Slug History ​

php
GET /articles/1/slug-history
→ 200
{
  "current_slug": "new-title",
  "slug_history": [
    {"old_slug": "my-first-post", "created_at": "..."}
  ]
}

// New article — empty history
{"current_slug": "fresh", "slug_history": []}

// Unknown article → 404
GET /articles/9999/slug-history → 404

History entries only accumulate when the slug actually changes. Updating body without changing the title leaves history untouched.


Vulnerability Assessment ​

V-01 — Path Traversal via Slug ✅ SAFE ​

Risk: Attacker sends GET /articles/by-slug/../../../etc/passwd to traverse server directories. Finding: SAFE — Slug lookups are SQL WHERE slug = ? with a bound parameter. The path segment is never interpreted as a filesystem path. Routing parses the path before it reaches the controller; ../ in a URL path is canonicalized by the HTTP layer.


V-02 — SQL Injection via Slug in URL ✅ SAFE ​

Risk: GET /articles/by-slug/' OR '1'='1 leaks all articles. Finding: SAFE — Slug is passed as a bound parameter in WHERE slug = ?. SQL injection is impossible regardless of the slug value.


V-03 — Slug Enumeration (Brute-Force Discovery) ⚠️ EXPOSED ​

Risk: Attacker iterates common slugs (/articles/by-slug/admin, /articles/by-slug/secret-doc) to discover private articles. Finding: EXPOSED — Slugs are predictable derivations of human-readable titles. No rate limiting or authentication is enforced on GET /articles/by-slug/{slug}. Mitigation: require authentication for private content; add per-IP rate limiting; consider opaque IDs for sensitive resources.


V-04 — Slug History IDOR ✅ SAFE ​

Risk: Attacker calls GET /articles/{id}/slug-history for another user's article to discover past titles. Finding: SAFE — Slug history is public metadata. If articles are public, their history is as well. If articles require authorization, apply the same auth check to the /slug-history endpoint consistently.


V-05 — Infinite Redirect Loop via Slug History ✅ SAFE ​

Risk: Article A renames to slug B; article B renames to slug A — GET /by-slug/a → redirect to B → redirect to A (infinite loop). Finding: SAFE — The implementation looks up the current slug in articles.slug, then checks slug_history only for old slugs. A 301 response always points to the current canonical. Clients following redirects reach the canonical in one hop.


V-06 — Slug Collision Abuse (Sequential Counter Exhaustion) ⚠️ EXPOSED ​

Risk: Attacker creates thousands of articles titled "popular" to reserve "popular-2" through "popular-9999", then deletes them — or to force expensive counter scanning. Finding: EXPOSED — No rate limiting on article creation. The makeUnique counter scan is O(n) DB queries. Mitigation: rate-limit POST /articles per user; cap slug counter at a reasonable limit (e.g. 99); use a random suffix after threshold.


V-07 — Explicit Slug Injection (Overwrite Another Article's Slug) ✅ SAFE ​

Risk: Attacker uses PUT /articles/2 {"slug": "popular"} where "popular" belongs to article 1. Finding: SAFE — articles.slug has a UNIQUE constraint. Attempting to set a slug already claimed by another article triggers a DB constraint violation, translated to 409 Conflict.


V-08 — Unicode/Homograph Slug Attack ⚠️ EXPOSED ​

Risk: Attacker creates an article with a Unicode title that normalizes to the same bytes as an existing ASCII slug (e.g. café → caf-) to create a visually confusing URL. Finding: EXPOSED — SlugHelper::fromTitle() uses preg_replace('/[^a-z0-9]+/', '-', strtolower($title)). Non-ASCII characters are replaced by -, which may cause unexpected collisions or empty slugs. Mitigation: normalize Unicode to ASCII transliteration (e.g. iconv) before slug generation; treat all non-ASCII as - after normalization.


V-09 — XSS via Title Stored in Slug ✅ SAFE ​

Risk: Title <script>alert(1)</script> produces slug script-alert-1-script — safe alphanumeric output. Finding: SAFE — SlugHelper::fromTitle() strips all non-alphanumeric characters to -. The slug output is always [a-z0-9-], making HTML injection impossible through the slug.


V-10 — Old Slug Lookup Reveals Renamed Content ⚠️ EXPOSED ​

Risk: Article renamed from "secret-plan-v1" to "public-announcement"; attacker uses old slug to discover the original title via the redirect response canonical_slug. Finding: EXPOSED — The 301 response exposes the new canonical slug, which may reveal the renamed content. The slug history endpoint also reveals all old names. For sensitive renames, tombstone old slugs without revealing the new location; or use opaque slugs.


VULN Summary ​

IDVulnerabilityFinding
V-01Path traversal via slug✅ SAFE
V-02SQL injection via slug✅ SAFE
V-03Slug enumeration⚠️ EXPOSED
V-04Slug history IDOR✅ SAFE
V-05Infinite redirect loop✅ SAFE
V-06Collision counter exhaustion⚠️ EXPOSED
V-07Explicit slug overwrite✅ SAFE
V-08Unicode homograph attack⚠️ EXPOSED
V-09XSS via title✅ SAFE
V-10Old slug reveals renamed content⚠️ EXPOSED

6 SAFE, 4 EXPOSED — Rate-limit article creation; add authentication for private content; normalize Unicode before slug generation; consider tombstone-only slug history for sensitive renames.


What NOT to do ​

Anti-patternRisk
Interpolate slug into SQL directlySQL injection via slug path parameter
Hard-delete slug history on article deleteOld URLs return 404 instead of 301; SEO and link rot
No UNIQUE constraint on articles.slugConcurrent inserts create duplicate slugs
Return old slug unchanged on title updateSlug drift — URL no longer reflects content
No counter cap in makeUniqueAttacker exhausts counter via bulk creation
Use !== to compare existing slugsType coercion surprises; always use === for slug comparison

Released under the MIT License.