Как создать sitemap для Symfony сайта

Правильная карта сайта (sitemap.xml) для Symfony сайтов, как создать и обновлять для быстрой индексации.

Sitemap — это файл со списком URL страниц сайта, которые поисковые системы должны проиндексировать. Для Symfony-проектов создание карты сайта (sitemap Symfony) решается с помощью специализированных бандлов или написания собственного контроллера. В этой статье рассмотрим оба подхода, сравним доступные инструменты и покажем, как настроить генерацию, кэширование и автоматизацию обновления sitemap. Опыт интеграции на проекте с 40 000 материалов показывает: правильный выбор архитектуры экономит десятки часов разработки и снижает нагрузку на сервер.

Перед погружением в технические детали рекомендую ознакомиться со статьёй «SEO-оптимизация Symfony сайта». В ней мы разбирали базовые практики индексации и ранжирования для PHP-фреймворков. Здесь же сосредоточимся именно на генерации XML-файлов sitemap.

Бандлы для генерации sitemap

Экосистема Symfony включает несколько бандлов для работы с sitemap. На практике разработчики чаще всего выбирают между presta/sitemap-bundle и thewilkybarkid/primates-sitemap. Первый — активно развивающийся проект с поддержкой Symfony 7 и гибкой системой событий. Второй — легковесный бандл, который не обновлялся с 2019 года и не совместим с современными версиями фреймворка. По состоянию на 2026 год для новых проектов мы используем presta/sitemap-bundle версии 4.2.

Сравним возможности на основе реальных требований к картам сайта.

Характеристика presta/sitemap-bundle 4.2 primates-sitemap 1.0
Поддержка Symfony 7.x Полная Отсутствует (Symfony 4)
Sitemap Index Автоматически при >50 000 URL Ручное управление
Изображения и видео Поддерживает расширения Не реализовано
Мультиязычность Через alternate links Требует ручной доработки
Кэширование Встроенный PSR-6 адаптер Нет
Консольные команды Дамп в файл и удаление кэша Только генерация
Лицензия MIT MIT

На проекте с каталогом товаров на 120 000 позиций бандл primates-sitemap привёл к дублированию сегментов. Пришлось переписывать логику руками. presta/sitemap-bundle справился штатными средствами за счёт секционирования. Дальнейшие примеры будут на этом бандле.

presta/sitemap-bundle

Бандл строится вокруг генератора SitemapGenerator и событийной системы. Вы подписываетесь на событие SitemapPopulateEvent, добавляете объекты URL и получаете готовый файл sitemap. Архитектура позволяет регистрировать несколько подписчиков для разных типов контента: статьи, товары, статические страницы. Каждый подписчик отвечает за свою секцию.

Установка выполняется через Composer:

composer require presta/sitemap-bundle

В современных версиях Symfony бандл автоматически регистрируется через config/bundles.php. Если вы используете гибкую структуру, дополнительных действий не требуется. Провайдер кэша, роутинг и базовые параметры подхватываются из конфигурации фреймворка.

Базовая настройка

Первым делом настраиваем маршрут, по которому будет отдаваться sitemap. Бандл поставляет контроллер, генерирующий ответ с типом application/xml. В файле config/routes/presta_sitemap.yaml добавляем:

presta_sitemap:
    resource: "@PrestaSitemapBundle/Resources/config/routing.xml"
    prefix:   /

Теперь при переходе на /sitemap.xml Symfony вызывает контроллер бандла. Если сайт мультиязычный и URL содержат префиксы локалей, можно указать префикс через параметры маршрута. В проекте интернет-магазина с русской и английской версиями мы зарегистрировали два маршрута с разными префиксами и параметром _locale.

После настройки маршрута sitemap возвращает пустой XML-документ, потому что не добавлено ни одного URL. Переходим к наполнению.

Добавление URL через EventListener

Создаём класс-подписчик на событие SitemapPopulateEvent. Внедряем в него репозитории Doctrine для получения данных из БД. Пример ниже покрывает три сущности: Article, Category и Page.

namespace App\EventListener;

use App\Repository\ArticleRepository;
use App\Repository\CategoryRepository;
use App\Repository\PageRepository;
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
use Presta\SitemapBundle\Sitemap\Url\UrlConcrete;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class SitemapListener implements EventSubscriberInterface
{
    public function __construct(
        private ArticleRepository  $articleRepository,
        private CategoryRepository $categoryRepository,
        private PageRepository     $pageRepository,
        private UrlGeneratorInterface $urlGenerator
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            SitemapPopulateEvent::class => 'populate',
        ];
    }

    public function populate(SitemapPopulateEvent $event): void
    {
        $section = $event->getSection();
        if ($section !== null && $section !== 'default') {
            return; // реагируем только на основную секцию
        }

        $this->addStaticUrls($event);
        $this->addArticleUrls($event);
        $this->addCategoryUrls($event);
        $this->addPageUrls($event);
    }
    // ... методы добавления
}

Метод getSubscribedEvents регистрирует подписку. Логика фильтрации по секции позволяет не дублировать URL, когда бандл запрашивает конкретную секцию (например, при генерации sitemap index).

Добавление статических URL выглядит так:

private function addStaticUrls(SitemapPopulateEvent $event): void
{
    $urls = [
        ['route' => 'homepage', 'priority' => 1.0, 'changefreq' => 'daily'],
        ['route' => 'contact',   'priority' => 0.7, 'changefreq' => 'monthly'],
    ];

    foreach ($urls as $item) {
        $url = new UrlConcrete(
            $this->urlGenerator->generate($item['route'], [], UrlGeneratorInterface::ABSOLUTE_URL),
            new \DateTimeImmutable('2026-01-01'), // lastmod
            $item['changefreq'] ?? 'weekly',
            $item['priority'] ?? 0.5
        );
        $event->getUrlContainer()->addUrl($url, 'default');
    }
}

Каждый URL — экземпляр UrlConcrete. Конструктор принимает абсолютный адрес, дату последнего изменения, частоту обновления и приоритет. Частота changefreq задаётся значениями: always, hourly, daily, weekly, monthly, yearly, never. Приоритет от 0.0 до 1.0. Яндекс и Google учитывают эти поля как рекомендации.

Динамические URL из базы данных добавляем через репозитории Doctrine:

private function addArticleUrls(SitemapPopulateEvent $event): void
{
    $articles = $this->articleRepository->findBy(['isPublished' => true]);

    foreach ($articles as $article) {
        $url = new UrlConcrete(
            $this->urlGenerator->generate(
                'article_show',
                ['slug' => $article->getSlug()],
                UrlGeneratorInterface::ABSOLUTE_URL
            ),
            $article->getUpdatedAt() ?? $article->getCreatedAt(),
            'weekly',
            0.8
        );
        $event->getUrlContainer()->addUrl($url, 'articles');
    }
}

Секция articles задаётся вторым аргументом метода addUrl. При количестве записей более 50 000 бандл автоматически разобьёт секцию на несколько файлов и сгенерирует sitemap index. Ручное управление секциями полезно для группировки по типам контента: товары, категории, новости. На проекте с блогом на 70 000 статей мы выделили секции blog_2024, blog_2025 и архив, чтобы избежать одного гигантского файла.

Если требуется передать дополнительные атрибуты, например теги изображений, можно декорировать UrlConcrete с помощью ImageUrlDecorator или VideoUrlDecorator. Пример для изображения:

use Presta\SitemapBundle\Sitemap\Url\Decorator\Image;
use Presta\SitemapBundle\Sitemap\Url\Decorator\ImageUrlDecorator;

$url = new UrlConcrete(/* параметры */);
$imageDecorator = new ImageUrlDecorator($url);
$image = new Image('https://example.com/uploads/article/123.jpg', 'Заголовок изображения');
$imageDecorator->addImage($image);
$event->getUrlContainer()->addUrl($imageDecorator, 'articles');

Такая декорация позволяет передать поисковикам дополнительные медиаданные без переопределения базовой логики бандла.

Для производительности при обходе больших таблиц используйте yield в кастомных методах репозитория или разбивайте запросы на порции с помощью findBy с limit и offset. На проекте с 200 000 товаров мы применили пакетную обработку через Query::toIterable() Doctrine. Это предотвратило рост потребления памяти до критических значений.

Кастомная генерация через Controller

Если проект простой и не требует гибкости бандла, можно создать sitemap вручную через контроллер. Такой подход даёт полный контроль над XML, но возлагает на разработчика ответственность за кэширование, сегментирование и обновление данных.

Создаём класс контроллера с методом, возвращающим объект Response. Указываем в атрибуте маршрута путь /sitemap.xml и формат ответа xml.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class SitemapController extends AbstractController
{
    #[Route('/sitemap.xml', name: 'sitemap', defaults: ['_format' => 'xml'])]
    public function index(): Response
    {
        $xml = $this->renderView('sitemap/sitemap.xml.twig', [
            'urls' => $this->getUrls(),
        ]);

        return new Response($xml, 200, [
            'Content-Type' => 'application/xml; charset=utf-8',
            'Cache-Control' => 'public, max-age=3600',
        ]);
    }

    private function getUrls(): array
    {
        // собрать массив URL из БД или статики
        return [
            [
                'loc' => 'https://example.com/',
                'lastmod' => '2026-05-20',
                'changefreq' => 'daily',
                'priority' => '1.0',
            ],
            // ...
        ];
    }
}

Шаблон в Twig может выглядеть так:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for url in urls %}
    <url>
        <loc>{{ url.loc }}</loc>
        {% if url.lastmod is defined %}
        <lastmod>{{ url.lastmod }}</lastmod>
        {% endif %}
        <changefreq>{{ url.changefreq|default('weekly') }}</changefreq>
        <priority>{{ url.priority|default('0.5') }}</priority>
    </url>
{% endfor %}
</urlset>

Такой подход оправдан для лендингов или сайтов-визиток, где не предполагается расширения функционала. На проекте одностраничного приложения с API мы применили кастомный контроллер, генерирующий sitemap из ответов API. Заголовки кэширования Cache-Control позволили разгрузить сервер.

Недостаток ручного метода — отсутствие автоматической обработки sitemap index. При росте количества URL придётся самостоятельно реализовывать логику разбивки и создания индексного файла. В бандле это работает из коробки.

Кэширование sitemap

Генерация sitemap для крупного проекта может выполняться несколько секунд. Чтобы не нагружать базу данных и процессор при каждом запросе поисковика, необходимо кэшировать результат. В presta/sitemap-bundle реализована поддержка PSR-6 совместимых хранилищ. По умолчанию используется сервис cache.app, если не настроен другой.

В конфигурационном файле config/packages/presta_sitemap.yaml можно указать конкретный адаптер и время жизни TTL:

presta_sitemap:
    cache:
        pool: cache.sitemap
        ttl: 3600 # секунд
    defaults:
        priority: 0.5
        changefreq: weekly

Здесь cache.sitemap — это кастомный пул, зарегистрированный в config/packages/cache.yaml, например с адаптером Redis:

framework:
    cache:
        pools:
            cache.sitemap:
                adapter: cache.adapter.redis
                provider: snc_redis.default

При использовании файлового кэша важно убедиться, что директория var/cache доступна для записи. На одном из проектов мы столкнулись с ошибкой прав после деплоя — кэш sitemap не создавался, и бандл генерировал файлы при каждом запросе. Решение: добавить команду разогрева кэша в скрипт post-deploy.

HTTP-кэширование — второй уровень защиты. На уровне веб-сервера или через заголовки можно указать Cache-Control: public, max-age=86400. Бандл автоматически устанавливает заголовок ETag на основе содержимого секций. Если ставить перед приложением Varnish или CloudFront, то при попадании в кэш запросы вообще не доходят до бэкенда. На проекте с посещаемостью 300 000 хостов в сутки мы настроили Varnish с TTL 12 часов для /sitemap.xml и отдельных сегментов. Это сократило нагрузку на 40%.

Инвалидация кэша — ключевой момент. При добавлении новой статьи sitemap должен обновиться. Самый прямолинейный способ — сброс всего кэша при изменении сущностей. Можно подписаться на Doctrine Events и вызывать инвалидацию PSR-6 пула. В Symfony это делается через EventSubscriber на postPersist и postUpdate:

namespace App\EventListener;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;
use Psr\Cache\CacheItemPoolInterface;

#[AsDoctrineListener(Events::postPersist)]
#[AsDoctrineListener(Events::postUpdate)]
class SitemapInvalidationListener
{
    public function __construct(private CacheItemPoolInterface $sitemapCache) {}

    public function postPersist(): void
    {
        $this->sitemapCache->clear();
    }

    public function postUpdate(): void
    {
        $this->sitemapCache->clear();
    }
}

Для тонкой инвалидации можно чистить только определённые секции по тегам. Бандл поддерживает тегированный кэш, если используемый пул реализует интерфейс TagAwareAdapterInterface. Тогда при добавлении URL в секцию articles можно проставлять тег, а при изменении статьи сбрасывать именно его.

Автоматизация

Ручной запуск генерации подходит для разработки. На боевом сервере обновление sitemap должно происходить по расписанию или по событию. Бандл предоставляет консольную команду presta:sitemap:dump, которая генерирует статические XML-файлы в публичную директорию. Это удобно для сайтов, где sitemap не может генерироваться динамически.

php bin/console presta:sitemap:dump --section=articles --target=public/sitemap_articles.xml
php bin/console presta:sitemap:dump --target=public/sitemap.xml

Команду можно добавить в Cron с периодом, соответствующим частоте обновления контента. На портале с новостями мы настроили Cron на каждые 15 минут:

*/15 * * * * /var/www/site/bin/console presta:sitemap:dump --target=public/sitemap.xml --env=prod

Если контент меняется редко, достаточно запускать дамп раз в сутки в часы минимальной нагрузки. Для интернет-магазина с изменяющимся ассортиментом мы использовали Symfony Messenger: при обновлении товара асинхронное сообщение запускало частичную перегенерацию секции. Это снижало нагрузку на базу в пиковые моменты.

Собственная консольная команда оправдана при нестандартной логике. Создаём класс, расширяющий Symfony\Component\Console\Command\Command. В методе execute инстанцируем генератор бандла или собственную логику и записываем результат в файл. Пример для проекта без бандла:

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $urls = $this->fetchUrls();
    $xml = $this->renderSitemap($urls);
    file_put_contents('public/sitemap.xml', $xml);

    return Command::SUCCESS;
}

После обновления файла полезно уведомлять поисковые системы через ping. В Google можно отправить GET-запрос на https://www.google.com/ping?sitemap=https://example.com/sitemap.xml. Яндекс принимает пинги через https://webmaster.yandex.ru/ping?sitemap=.... Однако в 2026 году более надёжным методом считается протокол IndexNow. Он поддерживается Яндексом, Bing, Naver и экспериментально Google. С помощью одного API-запроса можно оповестить несколько поисковиков о новом или обновлённом URL мгновенно.

Рекомендации по автоматизации и индексации мы подробно разбирали в статье «Symfony — SEO и индексация».

Для отправки уведомлений через IndexNow вы можете использовать сервис Index-Now.ru. Он формирует корректные запросы и автоматически оповещает Яндекс, Google и Bing о новых страницах вашего сайта. Интеграция сводится к вызову HTTP-клиента в Symfony-команде после генерации sitemap.

Часто задаваемые вопросы

Как часто нужно обновлять sitemap?

Ориентируйтесь на частоту появления новых страниц и изменения существующих. Для новостного портала — каждые 15-30 минут. Для блога с одной статьёй в неделю — раз в сутки. Если динамическая генерация через бандл, sitemap всегда актуален при запросе, но обязательно применять кэширование с инвалидацией по событиям.

Поддерживает ли Symfony создание sitemap index?

Да. Бандл presta/sitemap-bundle автоматически создаёт индексный файл при превышении 50 000 URL в одной секции или при общем большом количестве. Можно также вручную указать несколько секций, и бандл сгенерирует для каждой отдельный файл, связав их индексом.

Как исключить страницы из sitemap?

В подписчике на SitemapPopulateEvent вы сами решаете, какие URL добавлять. Не передавайте в контейнер страницы с меткой noindex, канонически неосновные версии, страницы пагинации, фильтрации и служебные маршруты. Логику фильтрации можно вынести в репозиторий Doctrine.

Как добавить изображения и видео в sitemap?

Используйте декораторы ImageUrlDecorator и VideoUrlDecorator из presta/sitemap-bundle. Они оборачивают существующий UrlConcrete и добавляют соответствующие элементы XML. Для изображений указывается URL и опционально заголовок, для видео — длительность, описание, ссылка на превью.

Работает ли sitemap Symfony с мультиязычными сайтами?

При правильной настройке — да. В подписчике нужно генерировать URL для каждой локали с использованием hreflang. Бандл предоставляет метод setAlternateUrls, где можно передать массив ссылок с указанием языка. Для каждого языка создаётся своя запись в sitemap с атрибутом rel="alternate".