Aller au contenu principal
La newsletter arrive ! Premier envoi très bientôt : laissez votre adresse pour la recevoir.

Redirections HTTP avec Symfony : de la règle au nettoyage mensuel.

Redirections HTTP avec Symfony : entité RedirectRule, listener kernel.request, cache Redis, validation anti-ReDoS, création automatique au changement de slug et purge mensuelle.

MAJ 13 min de lecture
Sommaire · 8

Renommer un slug, restructurer une arborescence, retirer un contenu : chacun de ces gestes laisse derrière lui une URL que quelqu'un a mise en signet et que Google a indexée. Un système de redirections existe pour que ces adresses continuent de mener quelque part.

Une table de redirections, c'est deux colonnes. Tout le reste est du cycle de vie.

Ce billet décrit le système en place sur ce blog, de la règle stockée en base jusqu'à la purge mensuelle : les deux appels PCRE qui décident du sort d'une requête, la validation qui refuse un pattern vulnérable au backtracking, le listener qui intercepte avant le routeur, les redirections créées automatiquement quand un contenu change d'adresse, et le critère qui décide qu'une règle peut disparaître.

La règle : une source, une cible, un code

Une règle est une entité Doctrine à trois champs utiles. Une source, une cible, un code de statut, plus quelques colonnes de service.

PHP
#[Assert\Choice(choices: [301, 302, 410], message: 'Le code de statut doit être 301, 302 ou 410.')]
#[ORM\Column(type: Types::INTEGER, options: ['default' => 301])]
public int $statusCode = 301;

#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
public bool $isRegex = false;

#[Assert\Range(notInRangeMessage: 'La priorité doit être entre {{ min }} et {{ max }}.', min: 0, max: 9999)]
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
public int $priority = 0;

Trois codes, trois intentions. Le 301 annonce un déménagement définitif et transmet l'autorité de l'ancienne URL à la nouvelle. Le 302 annonce un détour temporaire et ne transmet rien, ce qui est exactement ce qu'on veut quand le contenu peut revenir. Le 410 annonce une suppression définitive : il n'a pas de cible, et la validation de la cible est donc conditionnée au code.

PHP
#[Assert\When(
    expression: 'this.statusCode != 410',
    constraints: [new Assert\NotBlank(message: 'La cible est obligatoire.')],
)]

Une règle est exacte ou regex. Les exactes se résolvent par une comparaison de chaînes, les regex par un appel PCRE, et la priorité départage quand plusieurs regex matchent le même chemin. La recherche exploite cette différence de coût : on tente d'abord une correspondance exacte en SQL, puis on ne charge que les règles regex, qui se comptent sur les doigts d'une main sur un site normal.

PHP
public function findMatchingRule(string $path): ?RedirectRule
{
    // 1. Try exact match first (fast SQL lookup)
    $exactMatch = $this->findExactMatch($path);
    if ($exactMatch instanceof RedirectRule) {
        return $exactMatch;
    }

    // 2. Fall back to regex rules only (much smaller subset)
    return $this->findRegexMatch($path);
}

Les deux appels PCRE qui décident

Quand la règle est une regex, deux fonctions PHP décident du sort de la requête, et toutes les deux ont une valeur de retour piégeuse.

PHP
public function matches(string $path): bool
{
    if (!$this->isRegex) {
        return $this->source === $path;
    }

    // preg_match returns 1 on match, 0 on no match, false on error.
    // Using === 1 ensures errors (ReDoS backtrack limit hit) are treated as "no match".
    return @preg_match($this->compiledPattern, $path) === 1;
}

preg_match() répond trois choses à une question binaire : 1 si ça matche, 0 si ça ne matche pas, false si le moteur a échoué en route. Le réflexe consiste à écrire (bool) preg_match(...), et le réflexe tombe juste : false devient false, donc « pas de match », donc le routeur reprend la main. La comparaison === 1 produit le même résultat, en disant à voix haute que le cas d'erreur a été rangé du côté « pas de match ». La ligne coûte le même prix et se relit sans hésitation.

Le second appel, lui, se paye vraiment.

PHP
public function applyTarget(string $path): string
{
    if (!$this->isRegex) {
        return $this->target;
    }

    // preg_replace returns null on error (e.g. backtrack limit exceeded).
    // Fall back to the raw target rather than redirecting to an empty URL.
    return @preg_replace($this->compiledPattern, $this->target, $path) ?? $this->target;
}

preg_replace() retourne null en cas d'erreur. Sans le ??, un (string) null donne une chaîne vide, et une chaîne vide passée à RedirectResponse envoie le visiteur vers une URL vide. Le repli sur la cible brute produit au pire une redirection sans substitution, ce qui reste très au-dessus d'une redirection nulle part.

Les deux méthodes partagent leur pattern compilé, exposé par un property hook privé qui centralise l'échappement du délimiteur.

PHP
private string $compiledPattern {
    get => '#^' . str_replace('#', '\#', $this->source) . '$#';
}

Le validateur qui refuse un pattern trop gourmand

Les règles regex sont saisies dans l'admin, et un pattern mal formé se paye au moment où une requête le rencontre. La validation intervient donc à la saisie, sur trois axes : la syntaxe du pattern, la correspondance entre les backreferences de la cible et les groupes de capture de la source, et le risque de backtracking catastrophique.

Le troisième est le plus intéressant, parce qu'il ne s'analyse pas, il se mesure. La technique consiste à abaisser temporairement la limite de backtracking de PCRE, puis à lancer le pattern contre des chaînes construites pour le faire souffrir.

PHP
$testStrings = [
    str_repeat('a', 100),
    str_repeat('a/', 50),
    str_repeat('abc', 35),
];

$previousLimit = (string) \ini_get('pcre.backtrack_limit');
ini_set('pcre.backtrack_limit', '10000');

try {
    foreach ($testStrings as $testString) {
        @preg_match($regexPattern, $testString);

        if (preg_last_error() === \PREG_BACKTRACK_LIMIT_ERROR) {
            $this->context->buildViolation('Ce pattern regex risque de causer des problèmes de performance (backtracking excessif). Simplifiez le pattern ou évitez les quantificateurs imbriqués.')
                ->addViolation()
            ;

            return;
        }
    }
} finally {
    ini_set('pcre.backtrack_limit', $previousLimit);
}

La limite par défaut de PHP est à un million. À dix mille, un pattern exponentiel du genre (a+)+b explose sur cent caractères, là où un pattern honnête du genre /billet/(\d+)/(.*) passe sans transpirer. Le seuil se choisit dans cet écart.

Le finally fait plus de travail qu'il n'en a l'air. Sans lui, une violation levée en plein test laisserait la limite abaissée pour le reste du processus, et en worker mode ce processus sert les requêtes suivantes.

Le listener, avant le routeur

L'interception se fait sur kernel.request, à une priorité qui passe avant le routeur.

PHP
#[AsEventListener(event: KernelEvents::REQUEST, method: 'onKernelRequest', priority: 32)]
final readonly class RedirectListener

La déclaration par attribut range la classe du côté des listeners, pas des subscribers. Sur ce projet, c'est le critère de rangement : #[AsEventListener] va dans EventListener/, EventSubscriberInterface va dans EventSubscriber/.

Le corps suit un chemin court. On ignore les sous-requêtes et la racine, on cherche une règle, et sans règle on rend la main. Avec une règle, trois choses arrivent avant la réponse.

D'abord le 410, qui court-circuite : pas de cible à résoudre, on lève l'exception HTTP correspondante pour obtenir la page d'erreur du site avec le statut 410, plutôt qu'une réponse nue.

Ensuite une garde qui refuse de servir une redirection dont la cible résolue est le chemin demandé.

PHP
if ($finalTarget === $path) {
    $this->logger->warning('Skipped self-referential redirect rule', [
        'source' => $redirectRule->source,
        'target' => $redirectRule->target,
        'path' => $path,
    ]);

    return;
}

Une règle dont la source et la cible coïncident ne redirige pas, elle boucle, et le navigateur rend un ERR_TOO_MANY_REDIRECTS sur une URL par ailleurs vivante. Le return rend la main au routeur, qui sert la page normalement, et le warning signale la règle à nettoyer. La validation empêche déjà d'en créer une ; cette garde couvre celles qui seraient déjà en base.

Enfin, tout le corps est enveloppé dans un catch global, avec une exception près : les exceptions HTTP volontaires, dont le 410, sont relancées pour atteindre le kernel. Le reste est journalisé et avalé. Une panne du système de redirections dégrade le site en 404 ; elle ne doit pas le faire tomber en 500.

Le cache, et ce qu'il implique pour le comptage

Chaque requête qui n'est pas une redirection paye quand même la recherche. D'où un cache-aside sur le pool Redis applicatif, avec un TTL d'une heure et un tag qui permet l'invalidation ciblée quand une règle change dans l'admin.

PHP
return $this->cache->get(
    key: $cacheKey,
    callback: function (ItemInterface $item) use ($path): ?RedirectRule {
        $item->expiresAfter(3600);

        if ($item instanceof CacheItem) {
            $item->tag([CacheTag::Redirects->value]);
        }
        // …
    },
);

La clé est un MD5 du chemin, pour une longueur fixe quel que soit le chemin demandé. Si Redis tombe, l'appel est rattrapé et la recherche repart en base : le visiteur ne voit rien, seule la latence bouge.

Ce cache a une conséquence directe sur le comptage des hits. La règle qui revient du cache est une entité détachée, dont le hitCount vaut ce qu'il valait au moment de la mise en cache. Un $rule->recordHit() suivi d'un flush() réécrirait cette valeur périmée, et deux requêtes simultanées s'écraseraient l'une l'autre. L'incrément est donc laissé à la base.

PHP
private function recordHit(RedirectRule $redirectRule): void
{
    try {
        $this->entityManager->createQuery(
            'UPDATE App\Entity\Routing\RedirectRule r
             SET r.hitCount = r.hitCount + 1, r.lastAccessedAt = :now
             WHERE r.id = :id',
        )
            ->setParameter('id', $redirectRule->getId(), 'uuid')
            ->setParameter('now', new \DateTimeImmutable())
            ->execute()
        ;
    } catch (\Throwable $throwable) {
        $this->logger->error('Failed to record redirect hit', [
            'source' => $redirectRule->source,
            'target' => $redirectRule->target,
            'error' => $throwable->getMessage(),
        ]);
    }
}

Le comptage est du meilleur effort : une base momentanément indisponible fait perdre un compteur, jamais une redirection. Ce compteur sert plus loin, et c'est ce qui rend sa fiabilité importante.

Dernière subtilité de cache : l'écriture ci-dessus modifie la règle à chaque requête. L'invalidation du tag redirects ignore donc les champs purement analytiques, sinon le cache serait vidé à chaque hit.

PHP
private const array ANALYTICS_FIELDS = [
    'hitCount',
    'lastAccessedAt',
    'updatedAt',
];

Les redirections que personne ne saisit

La majorité des règles d'un site vivant ne sont pas écrites à la main. Elles naissent du cycle de vie des contenus, et c'est le service qui suit ce cycle qui les pose.

Quand un contenu publié change de slug, une 301 part de l'ancienne URL vers la nouvelle. Le service en profite pour aplatir les chaînes : toute règle qui pointait vers l'ancienne URL est réécrite vers la nouvelle. Trois renommages successifs laissent trois règles qui convergent directement, jamais une cascade de sauts.

Un cas mérite un traitement à part dans cette consolidation. Si un slug revient à une valeur qu'il avait déjà portée, la règle réécrite aurait pour cible sa propre source. Elle est alors devenue inutile, puisque le contenu vit désormais à cette adresse, et elle est supprimée.

PHP
if ($chainingRule->source === $newPath) {
    $entityManager->remove($chainingRule);

    $this->logger->info('Removed self-referential redirect on inverse slug rename', [
        'source' => $chainingRule->source,
        'old_target' => $oldPath,
    ]);

    continue;
}

Le changement de statut demande un traitement symétrique. Dépublier un billet le repasse en brouillon, mais son URL reste indexée et continue d'être demandée. Sans règle, elle répond 404, et toutes les 301 qui pointaient vers ce billet deviennent des chaînes qui finissent dans le vide. Une redirection valide vers une page morte reste une page morte.

PHP
private function handleStatusTransition(
    EntityManagerInterface $entityManager,
    AbstractContent $content,
    array $changeSet,
): void {
    if (!isset($changeSet['status']) || !$content instanceof Post) {
        return;
    }

    if ($content->status->isPubliclyVisible()) {
        $this->removeOwnSlugRedirect($entityManager, $content);

        return;
    }

    if ($this->hasSlug($content->lastPublicSlug)) {
        $this->createUnpublishRedirect($entityManager, $content, $content->lastPublicSlug);
    }
}

À la dépublication, une 302 emmène l'ancienne URL vers la catégorie du billet. En 302, parce qu'un billet dépublié peut revenir et qu'une permanente annoncerait un déménagement définitif qui n'en est pas un.

La branche du dessus est celle qu'on oublie facilement. À la republication, cette 302 doit être retirée, sans quoi le billet est masqué par sa propre redirection : l'URL existe, le contenu est en ligne, et le listener continue d'expédier les visiteurs vers la catégorie.

Un détail se cache dans la condition : la source est lastPublicSlug, pas le slug qu'on vient de quitter. Renommer un billet pendant qu'il est en brouillon produit un ancien slug que personne n'a jamais vu passer. Poser une règle depuis cette adresse remplirait la table de lignes que rien ne demandera.

La purge mensuelle, et ce qu'elle ne touche pas

Une table de redirections grossit sans jamais rétrécir toute seule. Un nettoyage mensuel s'en charge, et son critère mérite d'être lu deux fois.

PHP
public function findInactiveForCleanup(int $days = 30): array
{
    $threshold = new \DateTimeImmutable(\sprintf('-%d days', $days));

    $result = $this->createQueryBuilder('r')
        ->where('r.hitCount = 0')
        ->andWhere('r.updatedAt < :threshold')
        ->setParameter('threshold', $threshold)
        ->orderBy('r.updatedAt', 'ASC')
        ->getQuery()
        ->getResult()
    ;

    return $result;
}

Zéro hit, et pas touchée depuis trente jours. Le critère porte sur le compteur, jamais sur la date de dernière visite. Une règle qui a servi une seule fois sort du périmètre pour de bon, même si ce hit unique est ancien : une 301 qui a servi est une URL qui existe quelque part, dans un vieux message, dans un lien entrant, dans un signet de quelqu'un. Elle vaut mieux vivante.

Le service pose aussi un plafond de deux cents suppressions par passage, au-delà duquel il s'arrête en journalisant un avertissement. Aucun scénario légitime ne l'atteint. Il existe pour le scénario où le critère se met à mentir, par exemple si le comptage des hits casse en silence : sans plafond, une purge mensuelle qui croit voir une table entière à zéro la viderait.

Reste le cas du contenu supprimé sans successeur. Une 301 vers une page de listing serait un mensonge poli : la page d'atterrissage répond 200, les moteurs la classent en soft-404, et l'ancienne URL reste en purgatoire sans transmettre la moindre autorité. La règle passe donc en 410, qui annonce la suppression et fait sortir l'URL de l'index proprement.

Ces règles 410 sont comptées comme les autres quand elles servent. Une règle dont on ne compte pas les hits ressemble trait pour trait à une règle morte, et le nettoyage mensuel la traiterait comme telle.

Le mot de la fin

Le composant central de tout ça tient en une table, un listener et deux validateurs. C'est la partie qui se code en une après-midi et qui ne bouge plus.

Ce qui demande de l'attention se trouve autour : un contenu qu'on renomme, qu'on renomme à nouveau, qu'on dépublie, qu'on republie, qu'on retire pour de bon. Un système de redirections tient le registre des adresses qu'un site a portées, et il se maintient au rythme des contenus qui changent, pas à celui du code qui le sert.

La dernière fois que vous avez dépublié quelque chose, vous êtes allé voir ce que son URL est devenue ?

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).

Vous aimerez aussi

Newsletter

Un mail quand il y a quelque chose à dire

Pas de tracking, pas de pub, pas de "10 astuces pour…". Juste les nouveaux billets et parfois une réflexion en plus.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.