Skip to content

Gestion des slugs — Slugs d'URL uniques avec résolution de collision et historique ​

Générer des slugs sûrs pour les URL à partir de titres, résoudre les collisions automatiquement, et maintenir une table d'historique des slugs pour que les anciens slugs redirigent vers l'URL canonique sans casser les liens entrants.

Implémentation de référence : FT174 sluglog dans hideyukiMORI/NENE2-examples


Schéma ​

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

-- Anciens slugs conservés pour le support des redirections
CREATE TABLE slug_history (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    article_id  INTEGER NOT NULL,
    old_slug    TEXT    NOT NULL UNIQUE,  -- source de redirection ; UNIQUE empêche les doublons
    replaced_at TEXT    NOT NULL,
    FOREIGN KEY (article_id) REFERENCES articles(id)
);

Génération de slug ​

php
final class SlugHelper
{
    public static function fromTitle(string $title): string
    {
        $slug = mb_strtolower($title);
        $slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug);
        $slug = trim($slug, '-');
        return $slug !== '' ? $slug : 'untitled';
    }

    /**
     * @param callable(string): bool $exists  Retourne true si le slug est pris.
     */
    public static function makeUnique(string $base, callable $exists): string
    {
        if (!$exists($base)) {
            return $base;
        }
        $counter = 2;
        while ($exists("{$base}-{$counter}")) {
            $counter++;
        }
        return "{$base}-{$counter}";
    }
}

Vérification d'unicité — Inclure les deux tables ​

Lors de la vérification qu'un slug est "pris", vérifier à la fois articles.slug et slug_history.old_slug. Sinon, un nouvel article pourrait revendiquer un slug encore utilisé comme source de redirection :

php
private function slugExists(string $slug): bool
{
    return $this->db->fetchOne('SELECT id FROM articles WHERE slug = ?', [$slug]) !== null
        || $this->db->fetchOne('SELECT id FROM slug_history WHERE old_slug = ?', [$slug]) !== null;
}

Recherche de slug avec indice de redirection ​

php
public function findBySlugWithRedirect(string $slug): ?array
{
    // 1. Vérifier la colonne du slug actuel (200 OK)
    $article = $this->findBySlug($slug);
    if ($article !== null) {
        return ['found' => $article, 'redirect' => false];
    }

    // 2. Vérifier l'historique des slugs (indice de redirection 301)
    $row = $this->db->fetchOne(
        'SELECT article_id FROM slug_history WHERE old_slug = ?', [$slug],
    );
    if ($row === null) {
        return null;  // 404
    }

    $article = $this->findById((int) $row['article_id']);
    return $article !== null ? ['found' => $article, 'redirect' => true] : null;
}

Le handler retourne ensuite HTTP 301 avec canonical_slug et data :

json
// GET /articles/by-slug/old-title  →  301
{
  "redirect": true,
  "canonical_slug": "new-title",
  "data": { "id": 1, "slug": "new-title", ... }
}

Mise à jour du slug — Enregistrer l'historique ​

Quand un article est renommé, déplacer l'ancien slug vers slug_history :

php
if ($newSlug !== $article->slug) {
    // Insérer uniquement si pas déjà dans l'historique (idempotent)
    $alreadyIn = $this->db->fetchOne(
        'SELECT id FROM slug_history WHERE old_slug = ?', [$article->slug],
    );
    if ($alreadyIn === null) {
        $this->db->insert(
            'INSERT INTO slug_history (article_id, old_slug, replaced_at) VALUES (?, ?, ?)',
            [$id, $article->slug, $now],
        );
    }
}

Gestion des collisions lors de la mise à jour ​

Lors du calcul du nouveau slug pour un article mis à jour, exclure le slug actuel de l'article de la vérification "exists" — sinon il s'incrémenterait inutilement à -2 :

php
$newSlug = SlugHelper::makeUnique(
    $newSlugBase,
    fn (string $s): bool => $s !== $article->slug && $this->slugExists($s),
);

Endpoints ​

MéthodeCheminDescription
POST/articlesCréer un article — slug auto-dérivé du titre
GET/articles/{id}Obtenir par ID numérique
GET/articles/by-slug/{slug}Obtenir par slug (200 actuel / 301 historique / 404)
PUT/articles/{id}Mettre à jour titre/corps/slug ; ancien slug → historique
GET/articles/{id}/slug-historyLister les slugs historiques

Scénarios de collision ​

ScénarioRésultat
Premier "Hello World"hello-world
Deuxième "Hello World"hello-world-2
Troisième "Hello World"hello-world-3
Article renommé de hello vers un slug déjà pristaken-slug-2
Même titre, pas de changement de slugPas d'entrée dans l'historique, slug inchangé
Ancien slug correspond à une entrée historiqueRéponse de redirection 301

Structure de la couche domaine ​

src/Article/
├── Article.php
├── ArticleRepository.php   # create / findBySlug / findBySlugWithRedirect / update / slugHistory
├── SlugHelper.php          # fromTitle() + makeUnique()
└── ArticleNotFoundException.php

Voir aussi ​

Publié sous licence MIT.