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".