A Symfony bundle for generating XML sitemaps. Supports static routes, dynamic Doctrine entities, and extensive configuration options.
The bundle handles both dynamic generation via controller and static file generation via command.
Memory-efficient streaming prevents issues with large datasets.
ext-xmlwritercomposer require ecourty/sitemap-bundle
The bundle will be automatically registered in config/bundles.php if using Symfony Flex (otherwise, add it manually):
return [
// ...
Ecourty\SitemapBundle\SitemapBundle::class => ['all' => true],
];
Create config/packages/sitemap.yaml:
Example: Static routes only
sitemap:
base_url: 'https://example.com'
static_routes:
- route: 'homepage'
priority: 1.0
changefreq: 'daily'
Example: With dynamic entities
sitemap:
base_url: 'https://example.com'
static_routes:
- route: 'homepage'
priority: 1.0
changefreq: 'daily'
entity_routes:
- entity: 'App\Entity\Article'
route: 'article_show'
route_params:
slug: 'slug' # entity property -> route parameter
priority: 0.8
changefreq: 'weekly'
lastmod_property: 'updatedAt'
Only required if you want dynamic generation via /sitemap.xml.
If you only use static generation (sitemap:dump command), you can skip this step.
⚠️ Important: Dynamic generation only works for simple sitemaps (single file). If you're using sitemap indexes (use_index: true or above the configured URLs threshold, default to 50k URLs), you must use static generation.
Add the bundle routes to config/routes.yaml:
sitemap:
resource: '@SitemapBundle/Resources/config/routes.yaml'
Dynamic generation - visit in your browser:
https://example.com/sitemap.xml
Static generation - generate a file:
php bin/console sitemap:dump # Generates public/sitemap.xml
That's it! 🎉
Perfect for marketing sites, landing pages, or small websites with fixed pages:
sitemap:
base_url: 'https://mysite.com'
static_routes:
- route: 'homepage'
priority: 1.0
changefreq: 'daily'
- route: 'about'
- route: 'services'
- route: 'contact'
priority: 0.6
Static pages + dynamic articles from database:
sitemap:
base_url: 'https://myblog.com'
static_routes:
- route: 'homepage'
priority: 1.0
- route: 'blog_index'
priority: 0.9
entity_routes:
- entity: 'App\Entity\Article'
route: 'article_show'
route_params:
slug: 'slug'
priority: 0.8
changefreq: 'weekly'
lastmod_property: 'publishedAt'
Multiple entity types with different priorities:
sitemap:
base_url: 'https://myshop.com'
static_routes:
- route: 'homepage'
priority: 1.0
- route: 'catalog'
priority: 0.9
entity_routes:
# Products (high priority, frequently updated)
- entity: 'App\Entity\Product'
route: 'product_show'
route_params:
id: 'id'
slug: 'slug'
priority: 0.8
changefreq: 'daily'
lastmod_property: 'updatedAt'
query_builder_method: 'findActiveProducts' # Only published products
# Categories (medium priority)
- entity: 'App\Entity\Category'
route: 'category_show'
route_params:
slug: 'slug'
priority: 0.6
changefreq: 'weekly'
# Blog articles (lower priority)
- entity: 'App\Entity\BlogPost'
route: 'blog_show'
route_params:
slug: 'slug'
priority: 0.5
changefreq: 'monthly'
When you need simple filtering without creating custom repository methods, use conditions:
sitemap:
base_url: 'https://myblog.com'
entity_routes:
# Only published articles
- entity: 'App\Entity\Article'
route: 'article_show'
route_params:
slug: 'slug'
priority: 0.8
changefreq: 'weekly'
lastmod_property: 'updatedAt'
conditions: 'e.published = true AND e.deletedAt IS NULL'
# Only active products in stock
- entity: 'App\Entity\Product'
route: 'product_show'
route_params:
slug: 'slug'
priority: 0.7
changefreq: 'daily'
conditions: 'e.active = true AND e.stock > 0'
# Only upcoming events
- entity: 'App\Entity\Event'
route: 'event_show'
route_params:
id: 'id'
priority: 0.9
changefreq: 'daily'
conditions: 'e.startDate >= CURRENT_DATE()'
Note: Use the alias e in your DQL conditions. You cannot combine conditions with query_builder_method.
For sites with 50,000+ URLs, the bundle automatically creates a sitemap index:
sitemap:
base_url: 'https://bigsite.com'
use_index: 'auto' # Automatically split if > 50,000 URLs
index_threshold: 50000
entity_routes:
- entity: 'App\Entity\Product'
route: 'product_show'
route_params:
slug: 'slug'
# With 150,000 products, this creates:
# sitemap_entity_product_1.xml (50,000 URLs)
# sitemap_entity_product_2.xml (50,000 URLs)
# sitemap_entity_product_3.xml (50,000 URLs)
sitemap:
# Base URL of your site (required)
base_url: 'https://example.com'
# Sitemap index strategy:
# - 'auto': generate index if total URLs > index_threshold (default)
# - true: always generate index (even with few URLs)
# - false: never generate index, single sitemap.xml file
use_index: 'auto'
# URL count threshold for auto index mode
index_threshold: 50000
# Static routes (without parameters)
static_routes:
# Homepage with high priority
- route: 'homepage'
priority: 1.0
changefreq: 'daily'
lastmod: '-1 day' # Optional: relative time string
# Blog listing page
- route: 'blog_list'
priority: 0.9
changefreq: 'daily'
# About page
- route: 'about'
priority: 0.5
changefreq: 'monthly'
# Dynamic routes (with Doctrine entities)
entity_routes:
# Example: Song entities with custom repository method
- entity: 'App\Entity\Song'
route: 'song_show'
route_params:
uid: 'uid' # entity property -> route parameter
priority: 0.8
changefreq: 'weekly'
lastmod_property: 'updatedAt' # Optional: DateTime property
query_builder_method: 'getSitemapQueryBuilder' # Optional: repository method
# Example: Post entities with custom service
- entity: 'App\Entity\Post'
route: 'post_show'
route_params:
slug: 'slug'
priority: 0.7
changefreq: 'monthly'
lastmod_property: 'publishedAt'
query_builder_method: 'App\Service\PostSitemapService::getQueryBuilder' # Optional: FQCN::method
# Example: Product entities with DQL conditions
- entity: 'App\Entity\Product'
route: 'product_detail'
route_params:
id: 'id'
slug: 'slug'
priority: 0.6
changefreq: 'weekly'
| Option | Type | Default | Description |
|---|---|---|---|
base_url |
string | required | Base URL for absolute URLs |
use_index |
string|bool | 'auto' |
Index strategy: 'auto', true, false |
index_threshold |
int | 50000 |
URL count threshold for auto index |
static_routes[].route |
string | required | Symfony route name |
static_routes[].priority |
float | 0.5 |
Priority (0.0-1.0) |
static_routes[].changefreq |
string | 'weekly' |
Change frequency |
static_routes[].lastmod |
string|null | null |
Relative time (e.g., '-2 days') |
entity_routes[].entity |
string | required | Entity class name (FQCN) |
entity_routes[].route |
string | required | Symfony route name |
entity_routes[].route_params |
array | required | Property → parameter mapping |
entity_routes[].priority |
float | 0.5 |
Priority (0.0-1.0) |
entity_routes[].changefreq |
string | 'weekly' |
Change frequency |
entity_routes[].lastmod_property |
string|null | null |
DateTime property name |
entity_routes[].query_builder_method |
string|null | null |
Repository method OR FQCN::method |
entity_routes[].conditions |
string|null | null |
DQL WHERE clause |
Valid changefreq values: always, hourly, daily, weekly, monthly, yearly, never
Important:
query_builder_method and conditions simultaneously.query_builder_method can be a repository method name (e.g., 'getSitemapQueryBuilder') or a FQCN::method (e.g., 'App\Service\SitemapService::getArticlesQueryBuilder')Requires routes import - see step 2 in Quick Start.
Once routes are imported, access the dynamic sitemap at:
https://example.com/sitemap.xml
The sitemap is generated on-the-fly from your configuration and database.
⚠️ Important limitation: Dynamic generation only works for simple sitemaps (single sitemap.xml file). If your configuration generates a sitemap index with multiple files (due to use_index: true or exceeding the threshold), the controller will only serve the main sitemap.xml index file. The individual sitemap files (sitemap_static.xml, sitemap_entity_*.xml) will not be accessible via controller routes.
Recommended for:
use_index: false)For sitemap indexes, use static generation instead (see below).
No routes import needed - works out of the box after configuration.
Generate a static sitemap file:
# Generate to public/ directory (default)
php bin/console sitemap:dump
# Generate to custom directory (relative to public/)
php bin/console sitemap:dump --output=sitemaps
# Generate to absolute directory path
php bin/console sitemap:dump --output=/var/www/public/sitemaps
# Force overwrite existing files without confirmation
php bin/console sitemap:dump --force
Important: The --output option specifies a directory, not a file, because the generator may create multiple files:
sitemap.xmlsitemap.xml (index) + sitemap_static.xml, sitemap_entity_product.xml, etc.✅ Recommended for:
use_index: true or exceeding threshold)Required for sitemap indexes: Dynamic generation via controller cannot serve individual sitemap files. Use static generation to write all files to disk.
Tip: Run via cron to regenerate periodically:
# Regenerate sitemap every night at 3am
0 3 * * * cd /var/www && php bin/console sitemap:dump --force
When you have both static routes and dynamic entities with use_index: false:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- Static routes -->
<url>
<loc>https://example.com/</loc>
<lastmod>2026-01-05</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://example.com/about</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://example.com/blog</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<!-- Dynamic entity routes -->
<url>
<loc>https://example.com/article/symfony-best-practices</loc>
<lastmod>2026-01-03</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://example.com/article/php-8-features</loc>
<lastmod>2026-01-04</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://example.com/product/123/awesome-widget</loc>
<lastmod>2026-01-05</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
</urlset>
When use_index: true or URL count exceeds threshold, the bundle generates an index file referencing separate sitemaps per source.
Benefits:
sitemap.xml (index file):
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap_static.xml</loc>
<lastmod>2026-01-05T20:30:00+00:00</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap_entity_article.xml</loc>
<lastmod>2026-01-05T20:30:15+00:00</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap_entity_product_1.xml</loc>
<lastmod>2026-01-05T20:30:45+00:00</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap_entity_product_2.xml</loc>
<lastmod>2026-01-05T20:30:52+00:00</lastmod>
</sitemap>
</sitemapindex>
sitemap_static.xml (static routes only):
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2026-01-05</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://example.com/about</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>
sitemap_entity_article.xml (articles only):
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/article/symfony-best-practices</loc>
<lastmod>2026-01-03</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://example.com/article/php-8-features</loc>
<lastmod>2026-01-04</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<!-- ... more articles ... -->
</urlset>
Note: When a source has more than 50,000 URLs, it's automatically split into numbered files (sitemap_entity_product_1.xml, sitemap_entity_product_2.xml, etc.)
For better performance with filtering and optimization, create a custom repository method that returns a QueryBuilder:
// src/Repository/PostRepository.php
use Doctrine\ORM\QueryBuilder;
class PostRepository extends ServiceEntityRepository
{
public function getSitemapQueryBuilder(): QueryBuilder
{
return $this->createQueryBuilder('p')
->where('p.published = true')
->andWhere('p.deletedAt IS NULL')
->orderBy('p.updatedAt', 'DESC');
}
}
Important: Return a QueryBuilder, not the query result. The bundle will:
COUNT() for efficient countingtoIterable() for memory-efficient streamingThen reference it in config:
entity_routes:
- entity: 'App\Entity\Post'
route: 'post_show'
route_params:
slug: 'slug'
query_builder_method: 'getSitemapQueryBuilder'
For more flexibility, use any service (not just the entity's repository):
// src/Service/PostSitemapService.php
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
class PostSitemapService
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function getPublishedPostsQueryBuilder(): QueryBuilder
{
return $this->em->createQueryBuilder()
->select('p')
->from(Post::class, 'p')
->where('p.status = :published')
->setParameter('published', 'published')
->orderBy('p.publishedAt', 'DESC');
}
}
Configuration:
entity_routes:
- entity: 'App\Entity\Post'
route: 'post_show'
route_params:
slug: 'slug'
query_builder_method: 'App\Service\PostSitemapService::getPublishedPostsQueryBuilder'
Use DQL conditions for simple filtering without custom methods:
entity_routes:
- entity: 'App\Entity\Post'
route: 'post_show'
route_params:
slug: 'slug'
conditions: 'e.published = true AND e.deletedAt IS NULL'
Map multiple entity properties to route parameters:
entity_routes:
- entity: 'App\Entity\Product'
route: 'product_detail'
route_params:
category: 'category.slug' # Nested property
slug: 'slug'
priority: 0.8
Control how sitemaps are split:
sitemap:
# Auto mode (default): index if total URLs > threshold
use_index: 'auto'
index_threshold: 50000
# Always use index (even with few URLs)
use_index: true
# Never use index (single sitemap.xml)
use_index: false
Example with index:
sitemap.xml # Index file
sitemap_static.xml # Static routes
sitemap_entity_song.xml # Song entities
sitemap_entity_post_1.xml # Post entities (first 50k)
sitemap_entity_post_2.xml # Post entities (remaining)
For complex URL generation needs beyond static routes and Doctrine entities, implement a custom UrlProviderInterface.
This is the most powerful extension point in the bundle, giving you complete control over URL generation.
/category/{slug}/product/{id})This example shows how to generate URLs like /category/electronics/product/smartphone-x where both category and product slugs are needed:
// src/Service/ProductUrlProvider.php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use Ecourty\SitemapBundle\Contract\UrlProviderInterface;
use Ecourty\SitemapBundle\Enum\ChangeFrequency;
use Ecourty\SitemapBundle\Model\SitemapUrl;
use App\Entity\Product;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class ProductUrlProvider implements UrlProviderInterface
{
// Inject dependencies
public function getUrls(): iterable
{
// Join with Category to get both slugs in one query
$qb = $this->em->createQueryBuilder()
->select('p', 'c') // Select both product and category
->from(Product::class, 'p')
->innerJoin('p.category', 'c')
->where('p.active = true')
->andWhere('c.active = true')
->orderBy('p.updatedAt', 'DESC');
foreach ($qb->getQuery()->toIterable() as $product) {
// Generate URL with both category and product parameters
$path = $this->urlGenerator->generate('product_show', [
'categorySlug' => $product->getCategory()->getSlug(),
'productSlug' => $product->getSlug(),
]);
yield new SitemapUrl(
loc: rtrim($this->baseUrl, '/') . $path,
priority: 0.7,
changefreq: ChangeFrequency::WEEKLY,
lastmod: $product->getUpdatedAt(),
);
}
}
public function count(): int
{
// Implement the count efficiently
// $qb = $this->em->createQueryBuilder()
// ->select('COUNT(p.id)')
// ...
}
public function getSourceName(): string
{
return 'products';
}
}
Configuration:
# config/services.yaml
services:
App\Service\ProductUrlProvider:
arguments:
$baseUrl: '%sitemap.base_url%'
# Automatically tagged as 'sitemap.url_provider' via autoconfiguration
That's it! The provider will be automatically discovered and used. No additional configuration needed.
UrlProviderRegistry collects all URL providers via tagged servicesUrlProviderInterfaceThe bundle is designed for extensibility:
UrlProviderInterface for any URL source (see Extensibility section)sitemap.url_provider tagThe bundle automatically uses Doctrine's toIterable() to stream entities, preventing memory issues with large datasets.
What the bundle does internally:
// ✅ Automatic streaming - no memory issues with 100k+ entities
$query->toIterable();
// ❌ Would load all entities in memory at once
$query->getResult();
Your responsibility: Return a QueryBuilder from repository methods (not query results):
// ✅ Correct - return QueryBuilder
public function getSitemapQueryBuilder(): QueryBuilder
{
return $this->createQueryBuilder('p')
->where('p.published = true');
}
// ❌ Wrong - don't call getQuery() or toIterable()
public function getSitemapData(): iterable
{
return $this->createQueryBuilder('p')
->getQuery()
->toIterable(); // Bundle handles this automatically
}
use_index: 'auto')sitemap:
base_url: 'https://bigsite.com'
use_index: 'auto' # Splits at 50k URLs per file
entity_routes:
- entity: 'App\Entity\Product'
route: 'product_show'
route_params:
slug: 'slug'
query_builder_method: 'getActiveProductsQueryBuilder'
// Repository method with filtering
public function getActiveProductsQueryBuilder(): QueryBuilder
{
return $this->createQueryBuilder('p')
->where('p.active = true')
->andWhere('p.stock > 0')
->orderBy('p.updatedAt', 'DESC');
}
Result: Can handle millions of products with minimal memory usage.
Contributions are welcome! The project follows strict coding standards to maintain high code quality.
Setup:
composer install
Development cycle:
# Make your changes, then run quality checks:
composer qa # Runs all checks (phpstan, cs-check, tests)
# Or run individual checks:
composer phpstan # Static analysis (Level 9)
composer cs-check # Code style check (PSR-12)
composer cs-fix # Fix code style automatically
composer test # Run PHPUnit tests
Before submitting:
composer qa passes without errorsAll contributions must follow the project's coding standards:
declare(strict_types=1) in all filesSee AGENTS.md for detailed developer and AI agent guide.
MIT License - see LICENSE file for details.
How can I help you explore Laravel packages today?