Deep dive into Shopware SEO URLs — how they work, how to configure them, and how to avoid growth issues
Why this post (and what you’ll learn)
If you run a Shopware store with many products, variants, languages, and domains, SEO URLs quickly turn from “nice to have” into a systems topic. In this post, I walk through how Shopware builds and uses SEO URLs in practice: where you configure templates in the Admin, how Twig’s placeholder replacement keeps pages fast, what the generator writes to the database (including canonicals and history), and why row counts grow as your catalog and languages expand. I’ll also cover what to do when seo_path_info ends up identical to path_info , and share a pragmatic playbook to keep things tidy at scale.
Basics
What is an SEO URL in Shopware?
Shopware keeps two ideas of a URL:
- “Internal route” (aka path_info ) — the technical route resolved by Symfony Router, e.g. /detail/abcd-1234 .
- “SEO path” (aka seo_path_info ) — the nice, human‑ and search‑engine‑friendly path rendered from your SEO template, e.g. awesome-product/sku-123 .
Internally, Shopware maps between them so your storefront links can be pretty while controllers still run by their internal routes.
Configure SEO URL templates in the Admin
You do not need a plugin to start. Head to:
- Settings ➡️ Shop ➡️ SEO
- Section: SEO URL templates
- Entities: Product, Category, Landing page (and others depending on features)
Key facts:
- Per sales channel overrides: A default template applies to all sales channels; you can override per sales channel if you need different patterns.
- Twig templates: Templates are Twig strings with entity variables. For products, common variables include
product.translated.name , product.productNumber , and category/mainCategory depending on your setup. - Auto‑escaping: Shopware renders templates in a special autoescape mode using slugifyurlencode , so text becomes URL‑safe by default.
- Preview: The Admin shows previews for a given entity — use this to sanity‑check before regenerating at scale.
Example product template (simple and robust):
{{ product.translated.name }}/{{ product.productNumber }}
More advanced (with a main category slug if present):
{% if product.mainCategories|length %}
{{ product.mainCategories.first.category.translated.name }}/{{ product.translated.name }}/{{ product.productNumber }}
{% else %}
{{ product.translated.name }}/{{ product.productNumber }}
{% endif %}
Regeneration tips:
- Changing a template will trigger regeneration for affected routes across sales channels and languages. For large catalogs, do this during low traffic windows.
- You can re‑index products/categories to (re)generate SEO URLs. Shopware updates URLs on relevant indexer events.
How SEO URLs are generated (under the hood)
When something changes (entity updates, template changes), Shopware runs the updater for the appropriate route (product, category, landing page). For each (salesChannel, language)
pair derived from your sales channel domains, Shopware:
- Loads the SEO URL template (default or sales channel override)
- Searches affected entities (e.g., all active, visible products for the sales channel)
- Renders the Twig template to seo_path_info
- Persists rows in the seo_url table and manages canonical flags and history
Where to look in core:
src/Core/Content/Seo/SeoUrlUpdater.php
// Selects DISTINCT sales channel + language from domains, then iterates those pairs
src/Storefront/Framework/Seo/SeoUrlRoute/ProductPageSeoUrlRoute.php
// prepareCriteria: include only active, visible products (variants included if visible)
src/Core/Content/Seo/SeoUrlGenerator.php
// For each entity: build internal path_info via Router, render Twig for seo_path_info
src/Core/Content/Seo/SeoUrlPersister.php
// Writes rows, flips canonicals, keeps non-canonical history (no automatic purge)
Database constraints, in short:
- Unique (language_id, sales_channel_id, seo_path_info)
- Unique (language_id, sales_channel_id, foreign_key, route_name, is_canonical)
This design lets you keep a canonical per entity while preserving non‑canonical history and manual overrides.
Pretty URLs missing? Fix templates that output internal routes
If your SEO templates accidentally render the internal route (e.g. calling path() / url() or concatenating the route pattern), your storefront will show technical‑looking links like /detail/abcd-1234 instead of human‑friendly slugs.
Checklist:
- Inspect your templates:
SELECT route_name, sales_channel_id, template
FROM seo_url_template
WHERE route_name IN ('frontend.detail.page','frontend.navigation.page','frontend.landing.page');
- Fix templates to output slugs or human strings (not the internal route). Example safe product template:
{{ product.translated.name }}/{{ product.productNumber }}
- Regenerate SEO URLs (during low traffic), and verify that placeholders in storefront HTML are replaced with the expected SEO paths.
Canonical URLs and variants
Variants can each get their own SEO URL by default (they are real product entities). In the storefront, you can set a canonical product for a variant, so search engines don’t see duplicates. The product‑detail template emits a canonical tag pointing to the selected canonical product (often the parent).
Practical guidance:
- If your variant set is large, prefer a single canonical URL (usually the parent) to avoid SEO noise.
In Admin, set canonicalProductId for variants where appropriate. - You can also adapt your product SEO template to reduce variant‑specific fragments if that matches your business logic.
Storefront integration: Twig seoUrl
and placeholder replacement
Templates should not query the seo_url table per link. Instead, Shopware’s Twig function seoUrl() emits a placeholder that is swapped for the SEO path in one batched query after rendering. This keeps pages fast even with dozens of links.
Where this happens in core:
src/Core/Framework/Adapter/Twig/Extension/SeoUrlFunctionExtension.php
public function seoUrl(string $name, array $parameters = []): string
{
return $this->seoUrlReplacer->generate($name, $parameters);
}
src/Core/Content/Seo/SeoUrlPlaceholderHandler.php
public function generate($name, array $parameters = []): string
{
$path = $this->router->generate($name, $parameters);
// returns marker like 124c71d5.../detail/abcd#
return self::DOMAIN_PLACEHOLDER . $path . '#';
}
public function replace(string $content, string $host, SalesChannelContext $context): string
{
// Finds all placeholders, performs a single batched query:
// SELECT seo_path_info FROM seo_url WHERE path_info IN (...)
// filtered by language, sales channel, is_canonical, is_deleted = 0
// Then swaps placeholders with SEO URLs
}
This avoids N database lookups while rendering — Shopware does one batched replacement pass instead.
Update Shopware 6.7: store API now includes SEO URLs everywhere
As of 6.7, Shopware generates SEO links directly on the sales‑channel category entity and exposes them to navigation consumers (including footer navigation). This matters if you read navigation via the store API.
What changed:
- Categories (incl. landing pages) now carry a seoLink field generated via the core CategoryUrlGenerator .
- The previous CmsLinksForStoreApiSubscriber is removed; you no longer need a subscriber to post‑process links in store API responses.
- Footer/navigation links for landing pages now resolve to proper SEO URLs out‑of‑the‑box.
Implications for integrators:
- Prefer seoLink when present instead of manually building links from path_info .
- If you had a custom subscriber that rewrote CMS links in store API responses, remove it or guard it for pre‑6.7 only.
References:
Why counts grow (and when that’s expected)
Row count for a single “product family” roughly equals:
numVisibleEntitiesInFamily (parent + active variants) × numLanguages
A few clarifications:
- Multiple domains in the same sales channel do not multiply rows if they share language — generation uses
distinct (salesChannel, language) pairs from domains. - If you have 9 variants and 17 languages, you get ~153 rows for that one family. That looks big, but is by design.
Monitoring queries:
-- Rows per route
SELECT route_name, COUNT(*)
FROM seo_url
GROUP BY route_name
ORDER BY COUNT(*) DESC;
-- Rows per product id and language (product detail route)
SELECT language_id, COUNT(*)
FROM seo_url
WHERE route_name = 'frontend.detail.page' AND foreign_key = UNHEX(:productId)
GROUP BY language_id;
History growth and cleanup strategy
The core keeps history — old canonical rows are retained (marked as non‑canonical), and soft deletes ( is_deleted ) are tracked. This is useful for redirects and auditability, but means tables grow over time.
Suggested maintenance (validate on staging first):
-- Remove soft-deleted, non-canonical rows
DELETE FROM seo_url
WHERE is_deleted = 1 AND is_canonical IS NULL;
-- Optionally prune old non-canonical rows beyond N days
DELETE FROM seo_url
WHERE is_canonical IS NULL AND is_deleted = 0
AND created_at < (NOW(3) - INTERVAL 90 DAY);
Also consider:
- Keep languages realistic per sales channel. Every extra language multiplies counts.
- Avoid frequent mass template changes — each change can generate new history rows for many entities.
If the table gets too big: a developer playbook
Below are practical, code‑level options you can adopt today. Mix and match as needed.
1) Add pruning as a scheduled command
Create a small Symfony console command in a plugin/app that deletes stale rows. Keep it idempotent and incremental.
// src/Command/SeoUrlPruneCommand.php (example sketch)
#[AsCommand(name: 'app:seo-url:prune')]
final class SeoUrlPruneCommand extends Command
{
public function __construct(private readonly Connection $connection) { parent::__construct(); }
protected function execute(InputInterface $input, OutputInterface $output): int
{
// 1) hard-delete soft-deleted rows in batches
$deleted = $this->connection->executeStatement(
'DELETE FROM seo_url WHERE is_deleted = 1 AND is_canonical IS NULL LIMIT 5000'
);
// 2) prune old non-canonical rows (keep recent 90 days)
$pruned = $this->connection->executeStatement(
"DELETE FROM seo_url\n WHERE is_canonical IS NULL AND is_deleted = 0\n AND created_at < (NOW(3) - INTERVAL 90 DAY)\n LIMIT 5000"
);
$output->writeln(sprintf('Deleted=%d, Pruned=%d', $deleted, $pruned));
return Command::SUCCESS;
}
}
Schedule via cron or your job runner (keep batches small to avoid long locks).
2) Tighten indexes for heavy reads
The placeholder replacement reads by (path_info, language_id, sales_channel_id, is_canonical, is_deleted=0) . Ensure MySQL has an efficient composite index:
CREATE INDEX idx_seo_url_lookup
ON seo_url (path_info(255), language_id, sales_channel_id, is_canonical, is_deleted);
Adjust prefix length for path_info if you use utf8mb4 and long paths.
3) Reduce generation for variants (plugin extension)
If your business rules allow, you can decorate the product SEO route to skip child variants and generate only for parents (while keeping canonical tags pointing to the parent). This lowers write‑amplification drastically.
Sketch (decorator):
// Decorate ProductPageSeoUrlRoute and override prepareCriteria
public function prepareCriteria(Criteria $criteria, SalesChannelEntity $salesChannel): void
{
parent::prepareCriteria($criteria, $salesChannel);
// Exclude children; keep only parent products
$criteria->addFilter(new EqualsFilter('parentId', null));
}
Validate resulting storefront behavior (variant selection, canonical links, filters) before rolling out widely.
4) Cap template volatility
Every template change can spawn a new generation for all entities. Gate template edits behind feature toggles and batch rollouts to reduce churn.
5) Partitioning and archiving (advanced)
For very large catalogs, consider table partitioning by route_name or by time, and/or moving old non‑canonical rows into an archive table. This keeps hot data small while retaining history.
Note on “one table per sales channel”: this is generally a bad idea in Shopware core. The platform expects a single seo_url repository; placeholder replacement, persister, updater, and Admin tooling all query that single table filtered by sales_channel_id and language_id . Splitting into N tables would require custom repositories, migrations, Admin changes, and careful handling of unique constraints and canonical updates. High risk, little gain.
Prefer DB‑level partitioning instead — you get operational benefits without rewriting core:
-- Key partitioning by sales_channel_id
ALTER TABLE seo_url
PARTITION BY KEY(sales_channel_id)
PARTITIONS 16;
-- Or time-based partitioning for history control
ALTER TABLE seo_url
PARTITION BY RANGE COLUMNS (created_at) (
PARTITION p2024q4 VALUES LESS THAN ('2025-01-01'),
PARTITION p2025q1 VALUES LESS THAN ('2025-04-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
Remember to validate partitioning against your MySQL/MariaDB version and confirm that all unique indexes remain valid.
Admin checklist (copy/paste)
- Review SEO URL templates in Settings ➡️ Shop ➡️ SEO
- Ensure templates don’t produce internal routes
- Define a canonical strategy for variants (often parent canonical)
- Limit enabled languages per sales channel to what you actually need
- Schedule regeneration in off‑peak windows
- Add periodic cleanup tasks for non‑canonical/soft‑deleted rows
Your turn — what worked for you?
I covered pruning jobs, indexes, variant‑generation cuts, and partitioning. What strategies have you used to keep seo_url
healthy at scale? Did you try parent‑only generation, archival tables, or alternative indexing? Drop a note — I’ll update this post with good ideas (and give you a shout‑out!).
Selected core files references
- Twig function ➡️ src/Core/Framework/Adapter/Twig/Extension/SeoUrlFunctionExtension.php
- Placeholder handler ➡️ src/Core/Content/Seo/SeoUrlPlaceholderHandler.php
- SEO URL generator ➡️ src/Core/Content/Seo/SeoUrlGenerator.php
- Updater (domain/language selection) ➡️ src/Core/Content/Seo/SeoUrlUpdater.php
- Product route (entity selection) ➡️ src/Storefront/Framework/Seo/SeoUrlRoute/ProductPageSeoUrlRoute.php
- Persister (canonicals/history) ➡️ src/Core/Content/Seo/SeoUrlPersister.php
- Canonical tag rendering (storefront) ➡️ src/Storefront/Resources/views/storefront/page/product-detail/meta.html.twig
- Core change (6.7): Landing page SEO in store API/footer ➡️ PR #8383
If you’re browsing on GitHub, open those paths in your repo to see the exact implementations.
Blog references
- Deep dive and the placeholder trick explained by Shopware: Deep Dive SEO Urls
- Background and examples (older but helpful): Shopware 6 – How do SEO URLs work?