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 :
#[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 :
/** 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 :
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 :
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 :
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 :
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 :
#[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 :
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 :
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 = falsequi utilise l'indexidx_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 avecpreg_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.
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 :
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 :
#[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 :
#[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
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 :
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 :
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 (v1 → v2 → v3) 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.