Deep dive into Shopware SEO URLs — how they work, how to configure them, and how to avoid growth issues


8/22/2025

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:

  1. Loads the SEO URL template (default or sales channel override)
  2. Searches affected entities (e.g., all active, visible products for the sales channel)
  3. Renders the Twig template to seo_path_info
  4. 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:

  1. 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');
  1. Fix templates to output slugs or human strings (not the internal route). Example safe product template:
{{ product.translated.name }}/{{ product.productNumber }}
  1. 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

If you’re browsing on GitHub, open those paths in your repo to see the exact implementations.

Blog references

Comments 💬 & Reactions ✨