Aller au contenu principal

Construire un système de redirections HTTP complet avec Symfony

Système de redirections HTTP avec Symfony : règles exactes et regex, cache Redis, tracking via Messenger et création automatique lors des changements de slugs
Catégorie

Symfony

Architecture, composants et patterns avancés du framework Symfony.

Lecture
12 min
Niveau
Intermédiaire
févr 14 2026
Partager

Un site qui renomme ses URLs sans redirection génère des 404 que les moteurs de recherche finissent par détecter. Renommage de slugs, restructuration d'URLs, migration de contenu… J’ai mis en place un mécanisme complet qui gère les redirections exactes et regex, avec du cache Redis, du tracking analytique asynchrone et une création automatique lors des changements de slugs.

L'entité RedirectRule

Chaque règle de redirection est représentée par une entité Doctrine RedirectRule. Elle utilise un UUID v4 comme identifiant, un index sur la colonne source pour des lookups rapides, et une contrainte d'unicité pour éviter les doublons :

PHP
#[ORM\Entity(repositoryClass: RedirectRuleRepository::class)]
#[ORM\Table(name: 'redirect_rule')]
#[ORM\Index(name: 'idx_redirect_source', columns: ['source'])]
#[ORM\UniqueConstraint(name: 'UNIQ_REDIRECT_SOURCE', fields: ['source'])]
#[ORM\HasLifecycleCallbacks]
#[NoCircularRedirect]
#[ValidRegexPattern]
class RedirectRule implements \Stringable
{
    use TimestampableTrait;

    #[ORM\Column(length: 255, unique: true)]
    public string $source = '';

    #[ORM\Column(length: 255)]
    public string $target = '';

    #[Assert\Choice(choices: [301, 302])]
    #[ORM\Column(type: Types::INTEGER, options: ['default' => 301])]
    public int $statusCode = 301;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    public ?\DateTimeImmutable $lastAccessedAt = null;

    #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
    public int $hitCount = 0;

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

    #[Assert\Range(min: 0, max: 9999)]
    #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
    public int $priority = 0;

    #[ORM\Column(name: 'id', type: 'uuid', unique: true)]
    #[ORM\Id]
    private ?Uuid $uuid = null;
}

Deux validateurs personnalisés protègent la cohérence des données :

  • @NoCircularRedirect : détecte les boucles en suivant la chaîne de redirections (A → B → C → A) jusqu'à une profondeur maximale de 5 niveaux. Pour les règles regex, il teste des chemins courants (/page, /article, /billet/123...) en résolvant les backreferences.

  • @ValidRegexPattern : vérifie trois choses — que le pattern source est un PCRE valide, que les backreferences dans la cible ($1, $2...) correspondent à des groupes de capture existants dans la source, et que le pattern ne provoque pas de backtracking catastrophique (protection ReDoS).

Property hooks

L'entité tire parti des property hooks pour exposer des propriétés calculées sans getter :

PHP
/** Label du code HTTP (utilisé dans l'admin et les exports). */
public string $statusLabel {
    get => match ($this->statusCode) {
        301 => 'Permanent',
        302 => 'Temporaire',
        default => (string) $this->statusCode,
    };
}

/** Label du type de règle (utilisé dans l'admin). */
public string $typeLabel {
    get => $this->isRegex ? 'Regex' : 'Exacte';
}

/**
 * Pattern PCRE compilé à partir du champ source.
 * Centralise l'échappement des délimiteurs utilisé par matches() et applyTarget().
 */
private string $compiledPattern {
    get => '#^'.str_replace('#', '\#', $this->source).'$#';
}

Le hook privé $compiledPattern factorise la logique de compilation du pattern PCRE qui était dupliquée dans les méthodes matches() et applyTarget(). Ces deux méthodes gèrent explicitement les erreurs PCRE — notamment le dépassement de la limite de backtracking :

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;
}

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;
}

Le point important ici : (bool) preg_match(...) serait un piège. Si preg_match retourne false (erreur PCRE), (bool) false donne false — ce qui semble correct, mais masque une erreur silencieuse. La comparaison === 1 est explicite et intentionnelle. Côté applyTarget(), preg_replace retourne null en cas d'erreur : sans le fallback ?? $this->target, un cast (string) null donnerait '' — soit une redirection vers une URL vide.

Tracking des hits

Chaque fois qu'une redirection est appliquée, l'entité enregistre l'événement :

PHP
public function recordHit(): void
{
    ++$this->hitCount;
    $this->lastAccessedAt = new \DateTimeImmutable();
}

Ces données servent à l'analytique (quelles redirections sont les plus utilisées) et au nettoyage. La méthode findObsolete(int $days = 365) du repository exclut intelligemment les règles récemment créées qui n'ont pas encore été accédées :

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

    return $this->createQueryBuilder('r')
        ->where('r.lastAccessedAt < :threshold')
        ->orWhere('r.lastAccessedAt IS NULL AND r.createdAt < :threshold')
        ->setParameter('threshold', $threshold)
        ->orderBy('r.lastAccessedAt', 'ASC')
        ->getQuery()
        ->getResult()
    ;
}

La clause AND r.createdAt < :threshold évite un faux positif classique : une règle créée il y a 5 minutes avec lastAccessedAt = null n'est pas obsolète — elle est simplement neuve.

Protection ReDoS dans le validateur

Le ValidRegexPatternValidator valide non seulement la syntaxe et les backreferences, mais détecte aussi les patterns vulnérables au backtracking catastrophique. La technique : tester le pattern avec une limite de backtracking temporairement réduite à 10 000 (au lieu de 1 000 000 par défaut) contre des chaînes représentatives :

PHP
private function validatePatternPerformance(string $pattern): void
{
    $regexPattern = '#^'.str_replace('#', '\#', $pattern).'$#';

    $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);
    }
}

Un pattern comme /(a+)+b/ sera rejeté dès la saisie dans l'admin. La limite de 10 000 est suffisamment basse pour détecter les patterns exponentiels sur des chaînes courtes (100 caractères), mais suffisamment haute pour ne pas générer de faux positifs sur des patterns légitimes comme /billet/(\d+)/(.*).

Pour le comptage des groupes de capture utilisé par la validation des backreferences, on ne peut pas se contenter d'un preg_match($pattern, '') — un pattern comme /billet/(\d+) ne matche pas la chaîne vide, donc les groupes ne sont pas capturés et le comptage échoue. Le validateur parse structurellement le pattern pour compter les parenthèses ouvrantes qui sont réellement capturantes (en excluant les groupes non-capturants (?:...), les lookaheads, les parenthèses échappées \(, et celles à l'intérieur de classes de caractères [(...)]).

L'intercepteur de requêtes : RedirectListener

Le composant central du système est un event listener Symfony qui s'exécute avant le routeur, à la priorité 32 sur l'événement kernel.request :

PHP
#[AsEventListener(event: KernelEvents::REQUEST, method: 'onKernelRequest', priority: 32)]
final readonly class RedirectListener
{
    public function __construct(
        private RedirectRuleRepository $redirectRuleRepository,
        #[Autowire(service: 'app_cache_pool')]
        private CacheInterface $cache,
        private MessageBusInterface $messageBus,
        private LoggerInterface $logger,
    ) {}
}

Le nommage est intentionnel : cette classe utilise #[AsEventListener], pas EventSubscriberInterface. C'est un listener, pas un subscriber — la distinction a son importance dans l'écosystème Symfony.

Algorithme de traitement

Pour chaque requête principale (les sous-requêtes ESI/Twig sont ignorées), le listener suit ce chemin :

  • Extraire le path de la requête
  • Ignorer les chemins vides et la racine /
  • Chercher une règle de redirection (cache Redis → base de données)
  • Si trouvée : dispatcher le hit recording asynchrone, appliquer les éventuelles substitutions regex, retourner une RedirectResponse
  • Si non trouvée : laisser le routeur Symfony prendre le relais

Tout est wrappé dans un try/catch global : une erreur dans le système de redirection ne doit jamais casser la requête. Le routeur normal prend la main.

Stratégie de cache à deux niveaux

La recherche de règle utilise un pattern cache-aside avec Redis :

PHP
private function getRedirectRuleWithCache(string $path): ?RedirectRule
{
    $cacheKey = \sprintf('redirect_rule_%s', md5($path));

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

                if ($item instanceof CacheItem) {
                    $item->tag(['redirects']);
                }

                return $this->redirectRuleRepository->findMatchingRule($path);
            },
        );
    } catch (\Throwable $throwable) {
        $this->logger->warning('Cache operation failed, falling back to database', [
            'path' => $path,
            'error' => $throwable->getMessage(),
        ]);

        return $this->redirectRuleRepository->findMatchingRule($path);
    }
}

Points clés :

  • Clé de cache : redirect_rule_{md5($path)} — le MD5 garantit une clé de longueur fixe compatible Redis
  • TTL : 3600 secondes (1 heure)
  • Tags : ['redirects'] — permet l'invalidation ciblée lorsqu'une règle est modifiée dans l'admin
  • Fallback : si Redis tombe, la requête passe directement en base. L'utilisateur ne voit aucune différence, seules les performances se dégradent légèrement

Le lookup two-tier du repository

Le RedirectRuleRepository::findMatchingRule() utilise une stratégie en deux temps pour optimiser les performances :

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

    // 2. Fall back to regex rules only (much smaller subset)
    return $this->findRegexMatch($path);
}
  • Match exact (SQL indexé) : une requête WHERE source = :path AND is_regex = false qui utilise l'index idx_redirect_source. Résolution quasi-instantanée.
  • Match regex (PHP) : charge uniquement les règles regex (typiquement < 10 règles) triées par priorité, et exécute $rule->matches($path) en PHP avec preg_match().

Cette approche évite de charger l'intégralité de la table en mémoire. Sur un site avec des centaines de règles exactes mais seulement quelques regex, la différence de performance est mesurable.

Tracking asynchrone via Symfony Messenger

Le tracking des hits de redirection est asynchrone : le listener dispatche un message via Symfony Messenger au lieu d'écrire directement en base de données pendant la requête HTTP.

PHP
private function dispatchHitRecording(RedirectRule $redirectRule): void
{
    try {
        $redirectRuleId = $redirectRule->getId();

        if ($redirectRuleId === null) {
            return;
        }

        $this->messageBus->dispatch(
            new RecordRedirectHitMessage((string) $redirectRuleId),
        );
    } catch (\Throwable $throwable) {
        $this->logger->error('Failed to dispatch redirect hit recording', [
            'source' => $redirectRule->source,
            'target' => $redirectRule->target,
            'error' => $throwable->getMessage(),
        ]);
    }
}

Le message est minimaliste — un simple UUID sous forme de string :

PHP
final readonly class RecordRedirectHitMessage
{
    public function __construct(
        public string $redirectRuleId,
    ) {}
}

Le handler récupère l'entité managée par Doctrine et enregistre le hit :

PHP
#[AsMessageHandler]
final readonly class RecordRedirectHitHandler
{
    public function __construct(
        private RedirectRuleRepository $redirectRuleRepository,
        private EntityManagerInterface $entityManager,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(RecordRedirectHitMessage $recordRedirectHitMessage): void
    {
        try {
            $redirectRule = $this->redirectRuleRepository->find(
                Uuid::fromString($recordRedirectHitMessage->redirectRuleId),
            );

            if (!$redirectRule instanceof RedirectRule) {
                $this->logger->warning('Redirect rule not found for hit recording', [
                    'redirect_rule_id' => $recordRedirectHitMessage->redirectRuleId,
                ]);

                return;
            }

            $redirectRule->recordHit();
            $this->entityManager->flush();
        } catch (\Throwable $throwable) {
            $this->logger->error('Failed to record redirect hit', [
                'redirect_rule_id' => $recordRedirectHitMessage->redirectRuleId,
                'error' => $throwable->getMessage(),
            ]);
        }
    }
}

Le handler ne re-throw pas les exceptions : le tracking est du best-effort analytics. Si la base est momentanément indisponible, on perd un compteur de hit, pas une redirection.

L'invalidation du cache fonctionne toujours correctement : le RedirectRuleCacheInvalidationSubscriber (un listener Doctrine sur postPersist/postUpdate/postRemove) filtre les changements purement analytiques (hitCount, lastAccessedAt, updatedAt) pour éviter d'invalider le cache à chaque hit.

Redirections automatiques lors des changements de slugs

L'un des aspects les plus importants du système est la création automatique de redirections lorsqu'un slug de contenu change. Le SlugGenerationSubscriber écoute les événements Doctrine prePersist et preUpdate :

PHP
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final readonly class SlugGenerationSubscriber
{
    public function __construct(
        private SlugGeneratorService $slugGeneratorService,
        private RedirectRuleRepository $redirectRuleRepository,
        private LoggerInterface $logger,
    ) {}
}

Fonctionnement

Lors d'un preUpdate, si le flag forceSlugUpdate est activé sur une entité de contenu (Post, Category, Page) :

  • Le slug courant est capturé avant la régénération
  • Un nouveau slug est généré à partir du titre via SlugGeneratorService
  • Si le slug a effectivement changé, une redirection 301 est créée automatiquement
PHP
public function preUpdate(PreUpdateEventArgs $preUpdateEventArgs): void
{
    $entity = $preUpdateEventArgs->getObject();

    if (!$entity instanceof AbstractContent) {
        return;
    }

    $oldSlug = $entity->slug;

    if (!$this->generateSlugIfNeeded($entity)) {
        return;
    }

    // Recompute changeset since we modified the entity
    $entityManager = $preUpdateEventArgs->getObjectManager();
    $classMetadata = $entityManager->getClassMetadata($entity::class);
    $entityManager->getUnitOfWork()->recomputeSingleEntityChangeSet($classMetadata, $entity);

    $newSlug = $entity->slug;

    if ($oldSlug !== null && $newSlug !== null && $oldSlug !== $newSlug) {
        $this->createRedirectForSlugChange($entityManager, $entity, $oldSlug, $newSlug);
    }
}

Construction du chemin

Le chemin URL est construit en fonction du type d'entité via une expression match :

PHP
private function buildContentPath(AbstractContent $content, string $slug): string
{
    return match (true) {
        $content instanceof Post => '/billets/'.$slug,
        $content instanceof Category => '/categories/'.$slug,
        $content instanceof Page => '/'.$slug,
        default => '/'.$slug,
    };
}

Consolidation des chaînes de redirections

Le cas le plus technique : que se passe-t-il quand un slug change plusieurs fois ? Si on a déjà une redirection A → B et que B est renommé en C, on ne veut pas créer une chaîne A → B → C (deux redirections en cascade). Le subscriber consolide automatiquement :

PHP
private function createRedirectForSlugChange(
    EntityManagerInterface $entityManager,
    AbstractContent $content,
    string $oldSlug,
    string $newSlug,
): void {
    $oldPath = $this->buildContentPath($content, $oldSlug);
    $newPath = $this->buildContentPath($content, $newSlug);

    $unitOfWork = $entityManager->getUnitOfWork();
    $classMetadata = $entityManager->getClassMetadata(RedirectRule::class);

    // Consolider les chaînes : A → old devient A → new
    $chainingRules = $this->redirectRuleRepository->findBy(['target' => $oldPath]);
    foreach ($chainingRules as $chainingRule) {
        $chainingRule->target = $newPath;
        $unitOfWork->recomputeSingleEntityChangeSet($classMetadata, $chainingRule);
    }

    // Vérifier si une règle existe déjà pour cette source (contrainte UNIQUE)
    $existingRule = $this->redirectRuleRepository->findBySource($oldPath);
    if ($existingRule instanceof RedirectRule) {
        $existingRule->target = $newPath;
        $unitOfWork->recomputeSingleEntityChangeSet($classMetadata, $existingRule);

        return;
    }

    // Créer une nouvelle règle 301
    $redirectRule = new RedirectRule();
    $redirectRule->source = $oldPath;
    $redirectRule->target = $newPath;
    $redirectRule->statusCode = 301;
    $redirectRule->priority = 100;

    $entityManager->persist($redirectRule);
    $unitOfWork->computeChangeSet($classMetadata, $redirectRule);
}

Résultat : un article renommé 3 fois (v1v2v3) aura toujours exactement deux règles :

  • /billets/v1/billets/v3 (consolidée)
  • /billets/v2/billets/v3 (créée lors du dernier renommage)

Aucune chaîne, aucune redirection en cascade. Google et les utilisateurs arrivent directement à la bonne URL en un seul hop.

Le mot de la fin

Ce système de redirections combine plusieurs patterns — cache-aside, two-tier lookup, consolidation de chaînes, messaging asynchrone.

Le point le plus critique reste la création automatique lors des changements de slugs : sans elle, chaque renommage génère des 404 que Google finit par détecter dans la Search Console. Avec elle, c'est transparent.

Les validateurs personnalisés garantissent l'intégrité des données — pas de boucle de redirection, pas de backreference orpheline, pas de pattern ReDoS — même en cas d'erreur de saisie dans l'admin.

Les méthodes matches() et applyTarget() gèrent explicitement les erreurs PCRE au lieu de les masquer derrière des casts silencieux.

Poursuivre la lecture

Sélectionné avec soin pour vous.

PHP

PHP 8.2 : la consolidation du système de types et modernisation de l’API

Analyse détaillée de PHP 8.2 : classes readonly généralisées, types DNF pour unions et intersections, refonte de l'API Random, et fin des propriétés dynamiques avec exemples

6 min de lecture
DevOps

FrankenPHP en dev c'est bien, en prod c'est encore mieux

Passez FrankenPHP en production ! Comment j'ai déployé sur mon VPS avec un Makefile survitaminé. On couvre le Dockerfile de prod, la CI/CD, et l'astuce de multiplexing SSH pour calmer fail2ban pour de bon.

14 min de lecture
DevOps

Comment FrankenPHP a relégué PHP-FPM et Nginx au stade de reliques

Stop à Nginx + PHP-FPM. Découvrez pourquoi FrankenPHP est la solution moderne pour un setup Docker simple et performant.

13 min de lecture