Sitemap dynamique avec Symfony et PrestaShop : implémentation

Sitemap dynamique avec Symfony et PrestaShop : implémentation
Un sitemap statique, c'est du code mort. Découvrez comment générer un sitemap XML dynamique qui scale jusqu'à 100 000 produits avec Symfony et PrestaShop 9.

Un sitemap XML dynamique n'est pas un luxe SEO : c'est la porte d'entrée de votre catalogue pour Google. En PrestaShop avec Symfony, générer un sitemap pour 10 000 produits, c'est l'opposé d'un simple dump statique. Voici comment implémenter une stratégie de sitemap qui scale, reste à jour et respecte les contraintes de Google.

Pourquoi un sitemap dynamique ?

Un sitemap statique, c'est du code mort au moment où vous ajoutez un produit. En e-commerce, votre catalogue change en continu : nouveaux produits, prix mis à jour, catégories supprimées. Un sitemap qui ne suit pas en temps réel devient inutile pour Google.

Les raisons principales :

  • Catalogs volumineux (>10k SKUs) : un sitemap statique dépasse les 50 MB, Google refuse de l'indexer
  • Évolution quotidienne du catalogue : produits désactivés, supprimés, réactivés doivent être reflétés immédiatement
  • Dernière modification (lastmod) : Google prioritise les URLs modifiées récemment, pas possible en statique
  • Subdivisions : sitemaps de sitemaps (sitemap index) pour rester sous 50 000 URLs par fichier
  • Priorité granulaire : catégories racine > catégories secondaires > produits variantés

Architecture Symfony pour un sitemap dynamique

PrestaShop 9+ étant bâti sur Symfony, vous pouvez intégrer un controller dédié qui génère le XML à la volée. Voici la structure minimale :

// src/Controller/SitemapController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use App\Repository\ProductRepository;
use App\Repository\CategoryRepository;

class SitemapController extends AbstractController
{
    public function __construct(
        private ProductRepository $products,
        private CategoryRepository $categories,
    ) {}

    #[Route('/sitemap.xml', name: 'sitemap_index', format: 'xml')]
    public function index(): Response
    {
        $catalogs = $this->generateSitemapIndex();

        return new Response(
            $this->renderView('sitemap/index.xml.twig', ['catalogs' => $catalogs]),
            Response::HTTP_OK,
            ['Content-Type' => 'application/xml; charset=utf-8']
        );
    }

    #[Route('/sitemap-categories.xml', name: 'sitemap_categories', format: 'xml')]
    public function categories(): Response
    {
        $items = $this->categories->findActive()->map(fn($cat) => [
            'url' => $this->generateUrl('category', ['slug' => $cat->getSlug()]),
            'lastmod' => $cat->getUpdatedAt()?->format('Y-m-d'),
            'priority' => 0.8,
        ]);

        return new Response(
            $this->renderView('sitemap/urls.xml.twig', ['items' => $items]),
            Response::HTTP_OK,
            ['Content-Type' => 'application/xml; charset=utf-8']
        );
    }

    #[Route('/sitemap-products-{page}.xml', name: 'sitemap_products', format: 'xml')]
    public function products(int $page = 1): Response
    {
        $pageSize = 40000;
        $offset = ($page - 1) * $pageSize;

        $items = $this->products->findActive()
            ->setFirstResult($offset)
            ->setMaxResults($pageSize)
            ->getQuery()
            ->getResult();

        if (empty($items)) {
            throw $this->createNotFoundException('Page not found');
        }

        $urls = array_map(fn($product) => [
            'url' => $this->generateUrl('product', ['slug' => $product->getSlug()]),
            'lastmod' => $product->getUpdatedAt()?->format('Y-m-d'),
            'priority' => 0.7,
            'changefreq' => 'weekly',
        ], $items);

        return new Response(
            $this->renderView('sitemap/urls.xml.twig', ['items' => $urls]),
            Response::HTTP_OK,
            ['Content-Type' => 'application/xml; charset=utf-8']
        );
    }

    private function generateSitemapIndex(): array
    {
        // Calculer le nombre de pages de produits
        $totalProducts = $this->products->countActive();
        $productPages = (int)ceil($totalProducts / 40000);

        $sitemaps = [
            ['url' => $this->generateUrl('sitemap_categories'), 'lastmod' => date('Y-m-d')],
        ];

        for ($i = 1; $i <= $productPages; $i++) {
            $sitemaps[] = [
                'url' => $this->generateUrl('sitemap_products', ['page' => $i]),
                'lastmod' => date('Y-m-d'),
            ];
        }

        return $sitemaps;
    }
}

Templates Twig pour le rendu XML

Les templates doivent générer du XML parfaitement formaté, avec encoding UTF-8 et respect du standard de Google :

{# templates/sitemap/index.xml.twig #}
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for sitemap in catalogs %}
    <sitemap>
        <loc>{{ sitemap.url }}</loc>
        {% if sitemap.lastmod %}<lastmod>{{ sitemap.lastmod }}</lastmod>{% endif %}
    </sitemap>
{% endfor %}
</sitemapindex>

{# templates/sitemap/urls.xml.twig #}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
{% for item in items %}
    <url>
        <loc>{{ item.url }}</loc>
        {% if item.lastmod %}<lastmod>{{ item.lastmod }}</lastmod>{% endif %}
        {% if item.changefreq %}<changefreq>{{ item.changefreq }}</changefreq>{% endif %}
        {% if item.priority %}<priority>{{ item.priority }}</priority>{% endif %}
    </url>
{% endfor %}
</urlset>

Cache et mise en jour

Générer un sitemap pour 100 000 produits à chaque requête, c'est du gaspillage. L'approche correcte : cache court + invalidation intelligente :

// Dans le controller
#[Route('/sitemap-products-{page}.xml', name: 'sitemap_products')]
#[Cache(smaxage: 3600, maxage: 1800)] // 1h en CDN, 30min client
public function products(int $page = 1): Response
{
    // ... logique produits
}

Pour la réindexation au-delà du cache :

  • Webhook au sync produit : chaque création/modification/suppression invalide le cache Symfony
  • Command cron quotidienne : régénère les sitemaps « propres » hors des pics de trafic
  • Ping Google : envoyer immédiatement https://www.google.com/ping?sitemap=https://shop.com/sitemap.xml après modification majeure
// src/Command/RefreshSitemapCommand.php
#[AsCommand(name: 'app:refresh:sitemap')]
class RefreshSitemapCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // Invalider le cache
        $this->cache->delete('sitemap_index');

        // Ping Google
        file_get_contents('https://www.google.com/ping?sitemap=' . urlencode($this->sitemapUrl));

        $output->writeln('✅ Sitemap refreshed and pinged');
        return 0;
    }
}

Pièges courants et solutions

Sitemap > 50 MB

Google refuse d'indexer les sitemaps > 50 MB ou > 50 000 URLs. Solution : subdiviser par entité (catégories, produits page 1-10, etc.) et utiliser un sitemap index.

URLs avec caractères spéciaux

Les ampersands, accents et apostrophes doivent être échappés en XML :

// ❌ Non valide
<loc>/produit/femme?couleur=bleu&taille=L</loc>

// ✅ Valide
<loc>/produit/femme?couleur=bleu&taille=L</loc>

// Utiliser htmlspecialchars() ou Twig autoescape
{{ item.url | escape('xml') }}

Lastmod obsolète

Ne pas inclure de lastmod si c'est une date non mise à jour. C'est contre-productif : Google pense que rien n'a changé alors que l'URL l'a été. Mettre à jour updated_at sur vos entités Doctrine :

// src/Entity/Product.php
#[ORM\Column(type: 'datetime', nullable: true)]
#[Gedmo\Timestampable(on: 'update')]
private ?\DateTimeInterface $updatedAt = null;

Validation et monitoring

Avant de soumettre à Google Search Console :

  • Valider le XML avec XML Sitemap Validator
  • Vérifier que les URLs sont accessibles (HTTP 200)
  • S'assurer que robots.txt n'est pas plus restrictif que le sitemap
  • Soumettre l'index principal dans Google Search Console
  • Monitorer dans GSC : "Sitemaps" → taux d'indexation et erreurs
# Test rapide : vérifier que chaque URL du sitemap est crawlable
curl -I https://shop.com/sitemap.xml | grep 200
curl https://shop.com/sitemap.xml | xmllint --noout - 2>&1

Checklist d'implémentation

  • ✅ Créer SitemapController avec routes pour index + categories + products
  • ✅ Implémenter la logique de pagination (40k URLs max par fichier)
  • ✅ Écrire templates Twig pour XML valide
  • ✅ Ajouter cache HTTP (smaxage 3600, maxage 1800)
  • ✅ Tester l'accès à /sitemap.xml dans le navigateur
  • ✅ Valider le XML avec un outil tiers
  • ✅ Configurer la Command de refresh (cron quotidienne)
  • ✅ Mettre à jour robots.txt avec chemin du sitemap
  • ✅ Soumettre dans Google Search Console
  • ✅ Monitorer le taux d'indexation

Conclusion

Un sitemap dynamique en PrestaShop/Symfony, c'est une affaire de quelques heures de développement pour un gain SEO considérable. La règle d'or : le sitemap doit refléter l'état réel de votre catalogue, pas figé à la date du build. Avec Symfony, les Twig et le cache HTTP intelligent, vous avez tous les outils pour scaler jusqu'à des millions d'URLs.

Vous avez un catalogue volumétrique et besoin d'affiner votre stratégie de crawl ? Contactez-moi pour une audit SEO technique complet !

Sources

Jonathan Le-Peru

Écrit par Jonathan Le-Peru

Développeur backend avec plus de 7 ans d'expérience, spécialisé dans la création de solutions e-commerce robustes avec Prestashop. Passionné par l'optimisation des performances et les bonnes pratiques de développement.