Repository: shopware/shopware
Pull request #8383: fix: #3784 Add SEO URLs for landing pages in footer navigation
Author: login: devin-ai-integration[bot]
Created: 2025-04-08T14:18:24Z | Merged: 2025-04-16T12:41:25Z

This PR fixes issue #3784 where SEO URLs were missing for landing pages in footer navigation.

The problem was that when a category has an internal link to a landing page, the SEO-friendly URL was not being used in the navigation. 

The root cause is that the SEO URLs where only resolved inside the twig templates, therefore the information was not available in the store API.

Loading them only in the Navigation and overriding the internalLink property would only locally fix the problem and also introduce a breaking change. 

Instead we now added a `seoLink`runtime field to the sales channel category, because when you load a category in the sales channel context you usually wanna link to it

This also allowed to get rid of the twig functions to create the SEO URLs in the template, as we can just use the same prop as the store api.


Diff:
diff --git a/UPGRADE-6.8.md b/UPGRADE-6.8.md
index 8752d0690c5..aa91a6884ac 100644
--- a/UPGRADE-6.8.md
+++ b/UPGRADE-6.8.md
@@ -80,6 +80,19 @@ The old classes are removed:
 
 # Storefront
 
+## Removed `category_url` and `category_linknewtab` twig functions
+
+The `category_url` and `category_linknewtab` twig functions have been removed. The data is now directly available in the category entities, therefore use `category.seoLink` or `category.shouldOpenInNewTab` instead.
+
+```diff
+<a class="link"
+-   href="{{ category_url(item) }}"
++   href="{{ item.seoLink }}"
+-   {% if category_linknewtab(item) %}target="_blank"{% endif %}
++   {% if item.shouldOpenInNewTab %}target="_blank"{% endif %}
+</a>
+```
+
 # App System
 
 ## Use `sw_macro_function` instead of usual `macro` in app scripts if you return values
diff --git a/changelog/_unreleased/2025-04-08-fix-seo-urls-for-landing-pages-in-footer-navigation.md b/changelog/_unreleased/2025-04-08-fix-seo-urls-for-landing-pages-in-footer-navigation.md
new file mode 100644
index 00000000000..2fc95aa83e5
--- /dev/null
+++ b/changelog/_unreleased/2025-04-08-fix-seo-urls-for-landing-pages-in-footer-navigation.md
@@ -0,0 +1,10 @@
+---
+title: Fix SEO URLs for landing pages in footer navigation
+issue: #3784
+---
+# Core
+* Added SEO URL generation for internal links in `sales_channel.category` entities and used that instead of `category_url` twig function
+* Added replacing of seo URL and media URL placeholder for all store api responses
+___ 
+# Storefront
+* Deprecated `category_url` and `category_linknewtab` twig function, use `category.seoLink` or `category.shouldOpenInNewTab` instead
diff --git a/src/Core/Content/Category/CategoryEntity.php b/src/Core/Content/Category/CategoryEntity.php
index b7a7bf03b67..8ddc0d4973c 100644
--- a/src/Core/Content/Category/CategoryEntity.php
+++ b/src/Core/Content/Category/CategoryEntity.php
@@ -408,6 +408,11 @@ public function setLinkNewTab(?bool $linkNewTab): void
         $this->linkNewTab = $linkNewTab;
     }
 
+    public function shouldOpenInNewTab(): bool
+    {
+        return $this->type === CategoryDefinition::TYPE_LINK && $this->getTranslation('linkNewTab');
+    }
+
     public function getInternalLink(): ?string
     {
         return $this->internalLink;
diff --git a/src/Core/Content/Category/SalesChannel/NavigationRoute.php b/src/Core/Content/Category/SalesChannel/NavigationRoute.php
index 84672b8fdb9..152f26042e2 100644
--- a/src/Core/Content/Category/SalesChannel/NavigationRoute.php
+++ b/src/Core/Content/Category/SalesChannel/NavigationRoute.php
@@ -39,7 +39,7 @@ class NavigationRoute extends AbstractNavigationRoute
     public function __construct(
         private readonly Connection $connection,
         private readonly SalesChannelRepository $categoryRepository,
-        private readonly EventDispatcherInterface $dispatcher
+        private readonly EventDispatcherInterface $dispatcher,
     ) {
     }
 
diff --git a/src/Core/Content/Category/SalesChannel/SalesChannelCategoryDefinition.php b/src/Core/Content/Category/SalesChannel/SalesChannelCategoryDefinition.php
index 71f35caf0cc..2e580eddb45 100644
--- a/src/Core/Content/Category/SalesChannel/SalesChannelCategoryDefinition.php
+++ b/src/Core/Content/Category/SalesChannel/SalesChannelCategoryDefinition.php
@@ -3,6 +3,10 @@
 namespace Shopware\Core\Content\Category\SalesChannel;
 
 use Shopware\Core\Content\Category\CategoryDefinition;
+use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\ApiAware;
+use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Runtime;
+use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
+use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
 use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
 use Shopware\Core\Framework\Log\Package;
 use Shopware\Core\System\SalesChannel\Entity\SalesChannelDefinitionInterface;
@@ -14,4 +18,20 @@ class SalesChannelCategoryDefinition extends CategoryDefinition implements Sales
     public function processCriteria(Criteria $criteria, SalesChannelContext $context): void
     {
     }
+
+    public function getEntityClass(): string
+    {
+        return SalesChannelCategoryEntity::class;
+    }
+
+    protected function defineFields(): FieldCollection
+    {
+        $fields = parent::defineFields();
+
+        $fields->add(
+            (new StringField('seo_link', 'seoLink'))->addFlags(new ApiAware(), new Runtime(['type', 'linkType', 'internalLink']))
+        );
+
+        return $fields;
+    }
 }
diff --git a/src/Core/Content/Category/SalesChannel/SalesChannelCategoryEntity.php b/src/Core/Content/Category/SalesChannel/SalesChannelCategoryEntity.php
new file mode 100644
index 00000000000..255291713ff
--- /dev/null
+++ b/src/Core/Content/Category/SalesChannel/SalesChannelCategoryEntity.php
@@ -0,0 +1,22 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\SalesChannel;
+
+use Shopware\Core\Content\Category\CategoryEntity;
+use Shopware\Core\Framework\Log\Package;
+
+#[Package('discovery')]
+class SalesChannelCategoryEntity extends CategoryEntity
+{
+    protected ?string $seoLink = null;
+
+    public function getSeoLink(): ?string
+    {
+        return $this->seoLink;
+    }
+
+    public function setSeoLink(string $seoLink): void
+    {
+        $this->seoLink = $seoLink;
+    }
+}
diff --git a/src/Core/Content/Category/Subscriber/CategorySubscriber.php b/src/Core/Content/Category/Subscriber/CategorySubscriber.php
index a6da2bb8adc..7f55a004bde 100644
--- a/src/Core/Content/Category/Subscriber/CategorySubscriber.php
+++ b/src/Core/Content/Category/Subscriber/CategorySubscriber.php
@@ -5,6 +5,7 @@
 use Shopware\Core\Content\Category\CategoryDefinition;
 use Shopware\Core\Content\Category\CategoryEntity;
 use Shopware\Core\Content\Category\CategoryEvents;
+use Shopware\Core\Content\Category\Service\AbstractCategoryUrlGenerator;
 use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityLoadedEvent;
 use Shopware\Core\Framework\Log\Package;
 use Shopware\Core\System\SalesChannel\Entity\SalesChannelEntityLoadedEvent;
@@ -20,8 +21,10 @@ class CategorySubscriber implements EventSubscriberInterface
     /**
      * @internal
      */
-    public function __construct(private readonly SystemConfigService $systemConfigService)
-    {
+    public function __construct(
+        private readonly SystemConfigService $systemConfigService,
+        private readonly AbstractCategoryUrlGenerator $categoryUrlGenerator,
+    ) {
     }
 
     public static function getSubscribedEvents(): array
@@ -40,6 +43,12 @@ public function entityLoaded(EntityLoadedEvent $event): void
         $salesChannelId = $event instanceof SalesChannelEntityLoadedEvent ? $event->getSalesChannelContext()->getSalesChannelId() : null;
 
         foreach ($event->getEntities() as $category) {
+            if ($event instanceof SalesChannelEntityLoadedEvent) {
+                $category->assign([
+                    'seoLink' => $this->categoryUrlGenerator->generate($category, $event->getSalesChannelContext()->getSalesChannel()),
+                ]);
+            }
+
             $categoryCmsPageId = $category->getCmsPageId();
 
             // continue if cms page is given and was not set in the subscriber
diff --git a/src/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriber.php b/src/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriber.php
deleted file mode 100644
index 003a2953a7e..00000000000
--- a/src/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriber.php
+++ /dev/null
@@ -1,186 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Shopware\Core\Content\Cms\Subscriber;
-
-use Shopware\Core\Content\Cms\Aggregate\CmsSlot\CmsSlotEntity;
-use Shopware\Core\Content\Cms\CmsPageCollection;
-use Shopware\Core\Content\Cms\Events\CmsPageLoadedEvent;
-use Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface;
-use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface;
-use Shopware\Core\Framework\Api\ApiDefinition\DefinitionService as ApiDefinition;
-use Shopware\Core\Framework\Log\Package;
-use Shopware\Core\PlatformRequest;
-use Shopware\Core\System\SalesChannel\SalesChannelContext;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\Request;
-
-/**
- * @internal
- */
-#[Package('discovery')]
-class CmsLinksForStoreApiSubscriber implements EventSubscriberInterface
-{
-    private string $delimiter = '###UNIQUE_DELIMITER###';
-
-    /**
-     * @internal
-     */
-    public function __construct(
-        private readonly SeoUrlPlaceholderHandlerInterface $seoUrlReplacer,
-        private readonly MediaUrlPlaceholderHandlerInterface $mediaUrlReplacer,
-    ) {
-    }
-
-    public static function getSubscribedEvents(): array
-    {
-        return [
-            CmsPageLoadedEvent::class => 'relativeLinksForStoreAPIOutput',
-        ];
-    }
-
-    public function relativeLinksForStoreAPIOutput(
-        CmsPageLoadedEvent $event
-    ): void {
-        $request = $event->getRequest();
-        if ($this->isStoreApiRequest($request)) {
-            $pages = $event->getResult();
-            $this->processCmsPageCollection($pages, $request->getSchemeAndHttpHost(), $event->getSalesChannelContext());
-        }
-    }
-
-    private function isStoreApiRequest(Request $request): bool
-    {
-        $routeScope = $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE);
-        if (\is_array($routeScope)) {
-            foreach ($routeScope as $scope) {
-                if ($scope === ApiDefinition::STORE_API) {
-                    return true;
-                }
-            }
-        }
-
-        return false;
-    }
-
-    private function processCmsPageCollection(
-        CmsPageCollection $pages,
-        string $schemeAndHttpHost,
-        SalesChannelContext $salesChannelContext
-    ): void {
-        // Step 1: Collect all static string content
-        [$contentMap, $concatenatedContent] = $this->collectStaticContent($pages);
-
-        // Step 2 & 3: Process the big string with replaceLinks and split it back into chunks
-        $transformedChunks = $this->processAndSplitContent($concatenatedContent, $schemeAndHttpHost, $salesChannelContext);
-
-        // Step 4: Replace the content with the transformed chunks (data property stays unchanged)
-        $this->replaceContent($pages, $transformedChunks, $contentMap);
-    }
-
-    /**
-     * @return array{array<string, array<string, string>>, string}
-     */
-    private function collectStaticContent(CmsPageCollection $pages): array
-    {
-        $contentMap = [];
-        $concatenatedContent = '';
-
-        foreach ($pages as $page) {
-            $elements = array_merge(
-                $page->getElementsOfType('text'),
-                $page->getElementsOfType('html')
-            );
-
-            foreach ($elements as $slot) {
-                $fields = [
-                    'translatedConfig' => $slot->getTranslated()['config']['content']['value'] ?? null,
-                    'config' => $slot->getConfig()['content']['value'] ?? null,
-                ];
-                foreach (array_filter($fields) as $field => $content) {
-                    $contentMap[$slot->getId()][$field] = $content;
-                    $concatenatedContent .= $content . $this->delimiter;
-                }
-            }
-        }
-
-        return [$contentMap, $concatenatedContent];
-    }
-
-    /**
-     * @return array<int, string>
-     */
-    private function processAndSplitContent(
-        string $concatenatedContent,
-        string $schemeAndHttpHost,
-        SalesChannelContext $salesChannelContext
-    ): array {
-        $concatenatedContentReplaced = $this->replaceLinks($concatenatedContent, $schemeAndHttpHost, $salesChannelContext);
-        if ($this->delimiter === '') {
-            return [];
-        }
-
-        return array_filter(explode($this->delimiter, $concatenatedContentReplaced));
-    }
-
-    private function replaceLinks(string $content, string $schemeAndHttpHost, SalesChannelContext $salesChannelContext): string
-    {
-        $content = $this->mediaUrlReplacer->replace($content);
-        $content = $this->seoUrlReplacer->replace($content, '', $salesChannelContext);
-
-        return (string) $this->removeSchemeAndHttpHostFromLinks($content, $schemeAndHttpHost);
-    }
-
-    private function removeSchemeAndHttpHostFromLinks(string $content, string $schemeAndHttpHost = ''): ?string
-    {
-        return preg_replace_callback(
-            '/<a.+?\s*href\s*=\s*["\']?([^"\'\s>]+)["\']?/',
-            function (array $matches) use ($schemeAndHttpHost) {
-                return str_replace($schemeAndHttpHost, '', $matches[0]);
-            },
-            $content
-        );
-    }
-
-    /**
-     * @param array<int, string> $transformedChunks
-     * @param array<string, array<string, string>> $contentMap
-     */
-    private function replaceContent(
-        CmsPageCollection $pages,
-        array $transformedChunks,
-        array $contentMap
-    ): void {
-        foreach ($pages as $page) {
-            foreach ($page->getAllElements() as $slot) {
-                if (!isset($contentMap[$slot->getId()])) {
-                    continue;
-                }
-
-                foreach ($contentMap[$slot->getId()] as $field => $originalContent) {
-                    $transformedChunk = array_shift($transformedChunks);
-                    if ($transformedChunk === null) {
-                        continue;
-                    }
-
-                    $this->replaceSlotContent($slot, $field, $transformedChunk);
-                }
-            }
-        }
-    }
-
-    private function replaceSlotContent(CmsSlotEntity $slot, string $field, string $transformedChunk): void
-    {
-        switch ($field) {
-            case 'translatedConfig':
-                $translated = $slot->getTranslated();
-                $translated['config']['content']['value'] = $transformedChunk;
-                $slot->setTranslated($translated);
-                break;
-            case 'config':
-                $config = $slot->getConfig();
-                $config['content']['value'] = $transformedChunk;
-                $slot->setConfig($config);
-                break;
-        }
-    }
-}
diff --git a/src/Core/Content/DependencyInjection/category.xml b/src/Core/Content/DependencyInjection/category.xml
index d00be710dc4..dd797a2be0c 100644
--- a/src/Core/Content/DependencyInjection/category.xml
+++ b/src/Core/Content/DependencyInjection/category.xml
@@ -87,6 +87,7 @@
 
         <service id="Shopware\Core\Content\Category\Subscriber\CategorySubscriber">
             <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
+            <argument type="service" id="Shopware\Core\Content\Category\Service\CategoryUrlGenerator"/>
             <tag name="kernel.event_subscriber"/>
         </service>
 
diff --git a/src/Core/Content/DependencyInjection/cms.xml b/src/Core/Content/DependencyInjection/cms.xml
index 7868c888e54..937522e10c3 100644
--- a/src/Core/Content/DependencyInjection/cms.xml
+++ b/src/Core/Content/DependencyInjection/cms.xml
@@ -78,12 +78,6 @@
             <tag name="kernel.event_subscriber"/>
         </service>
 
-        <service id="Shopware\Core\Content\Cms\Subscriber\CmsLinksForStoreApiSubscriber">
-            <argument type="service" id="Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface"/>
-            <argument type="service" id="Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface"/>
-            <tag name="kernel.event_subscriber"/>
-        </service>
-
         <service id="Shopware\Core\Content\Cms\Subscriber\CmsVersionMergeSubscriber">
             <tag name="kernel.event_subscriber"/>
         </service>
diff --git a/src/Core/Framework/Adapter/Twig/Extension/CategoryUrlExtension.php b/src/Core/Framework/Adapter/Twig/Extension/CategoryUrlExtension.php
index 751148ac1ea..09946fd6bac 100644
--- a/src/Core/Framework/Adapter/Twig/Extension/CategoryUrlExtension.php
+++ b/src/Core/Framework/Adapter/Twig/Extension/CategoryUrlExtension.php
@@ -2,15 +2,18 @@
 
 namespace Shopware\Core\Framework\Adapter\Twig\Extension;
 
-use Shopware\Core\Content\Category\CategoryDefinition;
 use Shopware\Core\Content\Category\CategoryEntity;
 use Shopware\Core\Content\Category\Service\AbstractCategoryUrlGenerator;
+use Shopware\Core\Framework\Feature;
 use Shopware\Core\Framework\Log\Package;
 use Shopware\Core\System\SalesChannel\SalesChannelContext;
 use Symfony\Bridge\Twig\Extension\RoutingExtension;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFunction;
 
+/**
+ * @deprecated tag:v6.8.0 - reason:remove-subscriber - Will be removed, use CategoryEntity directly
+ */
 #[Package('framework')]
 class CategoryUrlExtension extends AbstractExtension
 {
@@ -25,6 +28,10 @@ public function __construct(
 
     public function getFunctions(): array
     {
+        if (Feature::isActive('v6.8.0.0')) {
+            return [];
+        }
+
         return [
             new TwigFunction('category_url', $this->getCategoryUrl(...), ['needs_context' => true, 'is_safe_callback' => $this->routingExtension->isUrlGenerationSafe(...)]),
             new TwigFunction('category_linknewtab', $this->isLinkNewTab(...)),
@@ -36,6 +43,11 @@ public function getFunctions(): array
      */
     public function getCategoryUrl(array $twigContext, CategoryEntity $category): ?string
     {
+        Feature::triggerDeprecationOrThrow(
+            'v6.8.0.0',
+            'The "category_url" function is deprecated and will be removed in v6.8.0.0. Use SalesChannelCategoryEntity::getSeoLink() instead.'
+        );
+
         $salesChannel = null;
         if (\array_key_exists('context', $twigContext) && $twigContext['context'] instanceof SalesChannelContext) {
             $salesChannel = $twigContext['context']->getSalesChannel();
@@ -46,14 +58,11 @@ public function getCategoryUrl(array $twigContext, CategoryEntity $category): ?s
 
     public function isLinkNewTab(CategoryEntity $categoryEntity): bool
     {
-        if ($categoryEntity->getType() !== CategoryDefinition::TYPE_LINK) {
-            return false;
-        }
-
-        if (!$categoryEntity->getTranslation('linkNewTab')) {
-            return false;
-        }
+        Feature::triggerDeprecationOrThrow(
+            'v6.8.0.0',
+            'The "category_linknewtab" function is deprecated and will be removed in v6.8.0.0. Use CategoryEntity::shouldOpenInNewTab() instead.'
+        );
 
-        return true;
+        return $categoryEntity->shouldOpenInNewTab();
     }
 }
diff --git a/src/Core/System/DependencyInjection/sales_channel.xml b/src/Core/System/DependencyInjection/sales_channel.xml
index c3b2a561ffd..cff6d0efe7d 100644
--- a/src/Core/System/DependencyInjection/sales_channel.xml
+++ b/src/Core/System/DependencyInjection/sales_channel.xml
@@ -169,6 +169,8 @@
             <tag name="kernel.event_subscriber"/>
             <argument type="service" id="Shopware\Core\System\SalesChannel\Api\StructEncoder"/>
             <argument type="service" id="event_dispatcher"/>
+            <argument type="service" id="Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface"/>
+            <argument type="service" id="Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface"/>
         </service>
 
         <service id="Shopware\Core\System\SalesChannel\Api\StructEncoder">
diff --git a/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php b/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php
index 82c84624119..255144f693a 100644
--- a/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php
+++ b/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php
@@ -2,7 +2,11 @@
 
 namespace Shopware\Core\System\SalesChannel\Api;
 
+use Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface;
+use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface;
 use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\PlatformRequest;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
 use Shopware\Core\System\SalesChannel\StoreApiResponse;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -21,7 +25,9 @@ class StoreApiResponseListener implements EventSubscriberInterface
      */
     public function __construct(
         private readonly StructEncoder $encoder,
-        private readonly EventDispatcherInterface $dispatcher
+        private readonly EventDispatcherInterface $dispatcher,
+        private readonly SeoUrlPlaceholderHandlerInterface $seoUrlPlaceholderHandler,
+        private readonly MediaUrlPlaceholderHandlerInterface $mediaUrlPlaceholderHandler,
     ) {
     }
 
@@ -52,7 +58,20 @@ public function encodeResponse(ResponseEvent $event): void
 
         $encoded = $this->encoder->encode($response->getObject(), $fields);
 
-        $event->setResponse(new JsonResponse($encoded, $response->getStatusCode(), $response->headers->all()));
+        $jsonResponse = new JsonResponse(null, $response->getStatusCode(), $response->headers->all());
+        $jsonResponse->setEncodingOptions(\JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_UNESCAPED_SLASHES);
+        $jsonResponse->setData($encoded);
+
+        $content = $this->mediaUrlPlaceholderHandler->replace((string) $jsonResponse->getContent());
+
+        $salesChannelContext = $event->getRequest()->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
+        if ($salesChannelContext instanceof SalesChannelContext) {
+            $content = $this->seoUrlPlaceholderHandler->replace($content, '', $salesChannelContext);
+        }
+
+        $jsonResponse->setContent($content);
+
+        $event->setResponse($jsonResponse);
     }
 
     /**
diff --git a/src/Storefront/Resources/views/storefront/layout/breadcrumb.html.twig b/src/Storefront/Resources/views/storefront/layout/breadcrumb.html.twig
index a03c71fc130..ef4a4587147 100644
--- a/src/Storefront/Resources/views/storefront/layout/breadcrumb.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/breadcrumb.html.twig
@@ -21,7 +21,7 @@
                     {% for breadcrumbCategory in breadcrumbCategories %}
                         {% set key = breadcrumbCategory.id %}
                         {% set name = breadcrumbCategory.translated.name %}
-                        {% set categoryUrl = category_url(breadcrumbCategory) %}
+                        {% set categoryUrl = breadcrumbCategory.seoLink %}
 
                         {% block layout_breadcrumb_list_item %}
                             <li class="breadcrumb-item"
@@ -37,7 +37,7 @@
                                     <a href="{{ categoryUrl }}"
                                        class="breadcrumb-link {% if key is same as(categoryId) %} is-active{% endif %}"
                                        title="{{ name }}"
-                                       {% if category_linknewtab(breadcrumbCategory) %}target="_blank"{% endif %}
+                                       {% if breadcrumbCategory.shouldOpenInNewTab %}target="_blank"{% endif %}
                                        itemprop="item">
                                         <link itemprop="url"
                                               href="{{ categoryUrl }}">
diff --git a/src/Storefront/Resources/views/storefront/layout/footer/footer.html.twig b/src/Storefront/Resources/views/storefront/layout/footer/footer.html.twig
index 6a421755102..ae5bac1994e 100644
--- a/src/Storefront/Resources/views/storefront/layout/footer/footer.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/footer/footer.html.twig
@@ -73,8 +73,8 @@
                                         {% if root.category.type == 'folder' %}
                                             {{ root.category.translated.name }}
                                         {% else %}
-                                            <a href="{{ category_url(root.category) }}"
-                                               {% if category_linknewtab(root.category) %}target="_blank"{% endif %}
+                                            <a href="{{ root.category.seoLink }}"
+                                               {% if root.category.shouldOpenInNewTab %}target="_blank"{% endif %}
                                                title="{{ root.category.translated.name }}">
                                                 {{ root.category.translated.name }}
                                             </a>
@@ -122,8 +122,8 @@
                                                                         <div>{{ name }}</div>
                                                                     {% else %}
                                                                         <a class="footer-link"
-                                                                           href="{{ category_url(category) }}"
-                                                                           {% if category_linknewtab(category) %}target="_blank"{% endif %}
+                                                                           href="{{ category.seoLink }}"
+                                                                           {% if category.shouldOpenInNewTab %}target="_blank"{% endif %}
                                                                            title="{{ name }}">
                                                                             {{ name }}
                                                                         </a>
@@ -203,8 +203,8 @@
                                 {% block layout_footer_service_menu_item %}
                                     <li class="footer-service-menu-item">
                                         <a class="footer-service-menu-link"
-                                           href="{{ category_url(serviceMenuItem) }}"
-                                           {% if category_linknewtab(serviceMenuItem) %}target="_blank"{% endif %}
+                                           href="{{ serviceMenuItem.seoLink }}"
+                                           {% if serviceMenuItem.shouldOpenInNewTab %}target="_blank"{% endif %}
                                            title="{{ serviceMenuItem.translated.name }}">
                                             {{ serviceMenuItem.translated.name }}
                                         </a>
diff --git a/src/Storefront/Resources/views/storefront/layout/navbar/categories.html.twig b/src/Storefront/Resources/views/storefront/layout/navbar/categories.html.twig
index 4d16630c650..941fe010c13 100644
--- a/src/Storefront/Resources/views/storefront/layout/navbar/categories.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/navbar/categories.html.twig
@@ -10,7 +10,7 @@
             {% set category = treeItem.category %}
             {% set id = category.id %}
             {% set name = category.translated.name %}
-            {% set link = category_url(category) %}
+            {% set link = category.seoLink %}
 
             {% block layout_navbar_categories_item %}
                 <div class="{% if level == 0 %}{% if navigationMedia %}col-4 {% else %}col-3 {% endif %}{% endif %}{% if ((loop.index % 4 != 0) and (loop.index != 1)) %}navigation-flyout-col{% endif %}">
@@ -24,7 +24,7 @@
                             <a class="nav-item nav-item-{{ id }} nav-link nav-item-{{ id }}-link navigation-flyout-link is-level-{{ level }}"
                                href="{{ link }}"
                                itemprop="url"
-                               {% if category_linknewtab(category) %}target="_blank"
+                               {% if category.shouldOpenInNewTab %}target="_blank"
                                {% if category.linkType == 'external' %}rel="noopener noreferrer"{% endif %}
                                 {% endif %}
                                title="{{ name }}">
diff --git a/src/Storefront/Resources/views/storefront/layout/navbar/content.html.twig b/src/Storefront/Resources/views/storefront/layout/navbar/content.html.twig
index eb853e331d0..cc7f622815c 100644
--- a/src/Storefront/Resources/views/storefront/layout/navbar/content.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/navbar/content.html.twig
@@ -57,8 +57,8 @@
                         <div class="navigation-flyout-teaser">
                             {% block layout_navbar_content_row_content_teaser_image %}
                                 <a class="navigation-flyout-teaser-image-container"
-                                   href="{{ category_url(category) }}"
-                                   {% if category_linknewtab(category) %}target="_blank"
+                                   href="{{ category.seoLink }}"
+                                   {% if category.shouldOpenInNewTab %}target="_blank"
                                    {% if category.linkType == 'external' %}rel="noopener noreferrer"{% endif %}
                                     {% endif %}
                                    title="{{ name }}">
diff --git a/src/Storefront/Resources/views/storefront/layout/navbar/navbar.html.twig b/src/Storefront/Resources/views/storefront/layout/navbar/navbar.html.twig
index 66b9a2bbf8e..a5de21ca03c 100644
--- a/src/Storefront/Resources/views/storefront/layout/navbar/navbar.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/navbar/navbar.html.twig
@@ -54,9 +54,9 @@
                                 {% else %}
                                     <li class="nav-item nav-item-{{ id }} {% if hasChildren %}dropdown position-static{% endif %}">
                                         <a class="nav-link nav-item-{{ id }}-link root main-navigation-link p-2{% if hasChildren %} dropdown-toggle{% endif %}"
-                                           href="{{ category_url(category) }}"
+                                           href="{{ category.seoLink }}"
                                            {% if hasChildren %}data-bs-toggle="dropdown"{% endif %}
-                                           {% if category_linknewtab(category) %}target="_blank"{% endif %}
+                                           {% if category.shouldOpenInNewTab %}target="_blank"{% endif %}
                                            itemprop="url"
                                            title="{{ name }}">
                                             <span itemprop="name" class="main-navigation-link-text">{{ name }}</span>
diff --git a/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/active-item-link.html.twig b/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/active-item-link.html.twig
index 6fef9cfabc0..e8e562120e9 100644
--- a/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/active-item-link.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/active-item-link.html.twig
@@ -1,7 +1,7 @@
 {% block layout_navigation_offcanvas_navigation_category_headline %}
     <a class="nav-item nav-link navigation-offcanvas-headline"
-       href="{{ category_url(item) }}"
-       {% if category_linknewtab(item) %}target="_blank"{% endif %}
+       href="{{ item.seoLink }}"
+       {% if item.shouldOpenInNewTab %}target="_blank"{% endif %}
        itemprop="url">
         {% block layout_navigation_offcanvas_navigation_category_headline_text %}
             <span itemprop="name">
diff --git a/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/item-link.html.twig b/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/item-link.html.twig
index 10ed5bb8e1c..37ee66d3f3d 100644
--- a/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/item-link.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/item-link.html.twig
@@ -1,6 +1,6 @@
 {% set isActive = item.category.id == activeId %}
 {% set hasChildren = item.category.visibleChildCount > 0 %}
-{% set url = category_url(item.category) %}
+{% set url = item.category.seoLink %}
 {% set name = item.category.translated.name %}
 
 {% block layout_navigation_offcanvas_navigation_category_item_link %}
@@ -12,7 +12,7 @@
                     data-href="{{ path('frontend.menu.offcanvas', {navigationId: item.category.id}) }}"
                 {% endif %}
                itemprop="url"
-                {% if category_linknewtab(item.category) %}target="_blank"{% endif %}
+                {% if item.category.shouldOpenInNewTab %}target="_blank"{% endif %}
                title="{{ name }}">
                 {% block layout_navigation_offcanvas_navigation_categories_list_category_item_link_text %}
                     <span itemprop="name">{{ name }}</span>
diff --git a/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/show-active-link.html.twig b/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/show-active-link.html.twig
index 9c1dd55ef6a..5c649d99428 100644
--- a/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/show-active-link.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/navigation/offcanvas/show-active-link.html.twig
@@ -4,9 +4,9 @@
     <li class="navigation-offcanvas-list-item">
         {% block layout_navigation_offcanvas_navigation_categories_list_current_category_item_link %}
             <a class="nav-item nav-link navigation-offcanvas-link is-current-category"
-               href="{{ category_url(item) }}"
+               href="{{ item.seoLink }}"
                itemprop="url"
-               {% if category_linknewtab(item) %}target="_blank"{% endif %}
+               {% if item.shouldOpenInNewTab %}target="_blank"{% endif %}
                title="{{ name }}">
                 {% block layout_navigation_offcanvas_navigation_categories_list_current_category_item_text %}
                     <span itemprop="name">
diff --git a/src/Storefront/Resources/views/storefront/layout/sidebar/category-navigation.html.twig b/src/Storefront/Resources/views/storefront/layout/sidebar/category-navigation.html.twig
index 6e23e787202..a6656ee5c07 100644
--- a/src/Storefront/Resources/views/storefront/layout/sidebar/category-navigation.html.twig
+++ b/src/Storefront/Resources/views/storefront/layout/sidebar/category-navigation.html.twig
@@ -14,8 +14,8 @@
                 {% set categoryId = item.category.id %}
                 {% set categoryName = item.category.translated.name %}
                 {% set categoryType = item.category.type %}
-                {% set categoryUrl = category_url(item.category) %}
-                {% set categoryLinkNewTab = category_linknewtab(item.category) %}
+                {% set categoryUrl = item.category.seoLink %}
+                {% set categoryLinkNewTab = item.category.shouldOpenInNewTab %}
 
                 {% block layout_navigation_categories_list_entry %}
                     <li class="category-navigation-entry">
diff --git a/tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php b/tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
index c2d9c08a87b..1c19e9d6c34 100644
--- a/tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
+++ b/tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
@@ -4,9 +4,16 @@
 
 use PHPUnit\Framework\Attributes\Group;
 use PHPUnit\Framework\TestCase;
+use Shopware\Core\Content\Category\CategoryDefinition;
+use Shopware\Core\Content\Product\Aggregate\ProductVisibility\ProductVisibilityDefinition;
+use Shopware\Core\Content\Seo\SeoUrl\SeoUrlEntity;
+use Shopware\Core\Content\Test\Product\ProductBuilder;
 use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
+use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
 use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
 use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour;
+use Shopware\Core\Framework\Uuid\Uuid;
 use Shopware\Core\Test\Stub\Framework\IdsCollection;
 use Symfony\Bundle\FrameworkBundle\KernelBrowser;
 
@@ -239,6 +246,202 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['seoLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['seoLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        $found = false;
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/Test-Product/', $category['seoLink']);
+                $found = true;
+                break;
+            }
+        }
+
+        static::assertTrue($found, 'Product SEO URL not found in response');
+
+        $this->createSeoUrl(
+            'frontend.detail.page',
+            '/detail/' . $productId,
+            'custom-product-url',
+            $productId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        $found = false;
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/custom-product-url', $category['seoLink']);
+                $found = true;
+                break;
+            }
+        }
+
+        static::assertTrue($found, 'Product SEO URL not found in response');
+    }
+
+    public function testCategoryInternalLinkHasSeoUrl(): void
+    {
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category2'),
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertNotEmpty($category['seoLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.navigation.page',
+            '/navigation/' . $this->ids->get('category2'),
+            'custom-category-url',
+            $this->ids->get('category2')
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertStringContainsString('/custom-category-url', $category['seoLink']);
+            }
+        }
+    }
+
+    /**
+     * Helper method to create a pre-configured SEO URL for an entity
+     */
+    private function createSeoUrl(string $routeName, string $pathInfo, string $seoPathInfo, string $entityId): void
+    {
+        $criteria = new Criteria();
+        $criteria->addFilter(new EqualsFilter('foreignKey', $entityId));
+        $criteria->addFilter(new EqualsFilter('routeName', $routeName));
+
+        $existingSeoUrls = $this->getContainer()->get('seo_url.repository')
+            ->search($criteria, Context::createDefaultContext());
+
+        $data = [
+            'salesChannelId' => $this->ids->get('sales-channel'),
+            'routeName' => $routeName,
+            'pathInfo' => $pathInfo,
+            'seoPathInfo' => $seoPathInfo,
+            'isCanonical' => true,
+            'foreignKey' => $entityId,
+        ];
+
+        if ($existingSeoUrls->count() > 0 && $existingSeoUrls->first() !== null) {
+            $seoUrl = $existingSeoUrls->first();
+            static::assertInstanceOf(SeoUrlEntity::class, $seoUrl);
+            $data['id'] = $seoUrl->getId();
+        } else {
+            $data['id'] = Uuid::randomHex();
+        }
+
+        $this->getContainer()->get('seo_url.repository')->upsert([$data], Context::createDefaultContext());
+
+        $this->getContainer()->get('cache.object')->invalidateTags(['seo-url']);
+    }
+
+    /**
+     * Helper method to request the footer navigation with SEO URLs
+     *
+     * @return array<string, mixed>
+     */
+    private function requestFooterNavigationWithSeoUrls(): array
+    {
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/navigation/footer-navigation/footer-navigation',
+                [
+                    'includes' => [
+                        'category' => ['id', 'name', 'type', 'linkType', 'seoLink'],
+                    ],
+                ],
+                [],
+                ['HTTP_SW-INCLUDE-SEO-URLS' => 'true']
+            );
+
+        return json_decode($this->getResponseContent(), true, 512, \JSON_THROW_ON_ERROR);
+    }
+
     private function createData(): void
     {
         $data = [
diff --git a/tests/unit/Core/Content/Category/Subscriber/CategorySubscriberTest.php b/tests/unit/Core/Content/Category/Subscriber/CategorySubscriberTest.php
index a92bee505f2..fc4118ce800 100644
--- a/tests/unit/Core/Content/Category/Subscriber/CategorySubscriberTest.php
+++ b/tests/unit/Core/Content/Category/Subscriber/CategorySubscriberTest.php
@@ -8,6 +8,8 @@
 use Shopware\Core\Content\Category\CategoryDefinition;
 use Shopware\Core\Content\Category\CategoryEntity;
 use Shopware\Core\Content\Category\CategoryEvents;
+use Shopware\Core\Content\Category\SalesChannel\SalesChannelCategoryEntity;
+use Shopware\Core\Content\Category\Service\CategoryUrlGenerator;
 use Shopware\Core\Content\Category\Subscriber\CategorySubscriber;
 use Shopware\Core\Framework\Context;
 use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityLoadedEvent;
@@ -42,7 +44,7 @@ public function testEntityLoadedEvent(
         ?string $cmsPageIdAfterEvent,
         ?string $salesChannelId
     ): void {
-        $categorySubscriber = new CategorySubscriber($systemConfigService);
+        $categorySubscriber = new CategorySubscriber($systemConfigService, $this->createMock(CategoryUrlGenerator::class));
 
         if ($salesChannelId) {
             $event = new SalesChannelEntityLoadedEvent(new CategoryDefinition(), [$categoryEntity], $this->getSalesChannelContext($salesChannelId));
@@ -70,7 +72,7 @@ public static function entityLoadedEventDataProvider(): iterable
 
         yield 'It does not set cms page id if already set by the subscriber' => [
             self::getSystemConfigServiceMock(null, 'cmsPageId'),
-            self::getCategory('differentCmsPageId', true),
+            self::getCategory('differentCmsPageId', true, true),
             'differentCmsPageId',
             'differentCmsPageId',
             'salesChannelId',
@@ -94,7 +96,7 @@ public static function entityLoadedEventDataProvider(): iterable
 
         yield 'It uses salesChannel specific default' => [
             self::getSystemConfigServiceMock('salesChannelId', 'salesChannelSpecificDefault'),
-            self::getCategory(null, false),
+            self::getCategory(null, false, true),
             null,
             'salesChannelSpecificDefault',
             'salesChannelId',
@@ -120,9 +122,12 @@ private static function getSystemConfigServiceMock(?string $salesChannelId = nul
         ]);
     }
 
-    private static function getCategory(?string $cmsPageId, bool $cmsPageIdSwitched): CategoryEntity
+    private static function getCategory(?string $cmsPageId, bool $cmsPageIdSwitched, bool $salesChannelEntity = false): CategoryEntity
     {
         $category = new CategoryEntity();
+        if ($salesChannelEntity) {
+            $category = new SalesChannelCategoryEntity();
+        }
 
         if ($cmsPageId) {
             $category->setCmsPageId($cmsPageId);
diff --git a/tests/unit/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriberTest.php b/tests/unit/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriberTest.php
deleted file mode 100644
index b7754cb0446..00000000000
--- a/tests/unit/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriberTest.php
+++ /dev/null
@@ -1,189 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Shopware\Tests\Unit\Core\Content\Cms\Subscriber;
-
-use PHPUnit\Framework\Attributes\CoversClass;
-use PHPUnit\Framework\TestCase;
-use Shopware\Core\Content\Cms\Aggregate\CmsBlock\CmsBlockCollection;
-use Shopware\Core\Content\Cms\Aggregate\CmsBlock\CmsBlockEntity;
-use Shopware\Core\Content\Cms\Aggregate\CmsSection\CmsSectionCollection;
-use Shopware\Core\Content\Cms\Aggregate\CmsSection\CmsSectionEntity;
-use Shopware\Core\Content\Cms\Aggregate\CmsSlot\CmsSlotCollection;
-use Shopware\Core\Content\Cms\Aggregate\CmsSlot\CmsSlotEntity;
-use Shopware\Core\Content\Cms\CmsPageCollection;
-use Shopware\Core\Content\Cms\CmsPageEntity;
-use Shopware\Core\Content\Cms\Events\CmsPageLoadedEvent;
-use Shopware\Core\Content\Cms\Subscriber\CmsLinksForStoreApiSubscriber;
-use Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface;
-use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface;
-use Shopware\Core\Framework\Api\Context\ShopApiSource;
-use Shopware\Core\Framework\Context;
-use Shopware\Core\Framework\Log\Package;
-use Shopware\Core\Framework\Struct\ArrayStruct;
-use Shopware\Core\System\SalesChannel\SalesChannelContext;
-use Symfony\Component\HttpFoundation\Request;
-
-/**
- * @internal
- */
-#[Package('discovery')]
-#[CoversClass(CmsLinksForStoreApiSubscriber::class)]
-class CmsLinksForStoreApiSubscriberTest extends TestCase
-{
-    private CmsLinksForStoreApiSubscriber $subscriber;
-
-    protected function setUp(): void
-    {
-        $seoUrlReplacer = $this->createMock(SeoUrlPlaceholderHandlerInterface::class);
-        $seoUrlReplacer->method('replace')->willReturnCallback(function (string $content) {
-            return $content;
-        });
-        $mediaUrlReplacer = $this->createMock(MediaUrlPlaceholderHandlerInterface::class);
-        $mediaUrlReplacer->method('replace')->willReturnCallback(function (string $content) {
-            if (str_contains($content, $this->getOriginalStaticContent())) {
-                return $this->getTransformedContent();
-            }
-
-            return $content;
-        });
-
-        $this->subscriber = new CmsLinksForStoreApiSubscriber(
-            $seoUrlReplacer,
-            $mediaUrlReplacer
-        );
-    }
-
-    public function testGetSubscribedEvents(): void
-    {
-        $events = CmsLinksForStoreApiSubscriber::getSubscribedEvents();
-        static::assertArrayHasKey(CmsPageLoadedEvent::class, $events);
-        static::assertEquals('relativeLinksForStoreAPIOutput', $events[CmsPageLoadedEvent::class]);
-    }
-
-    public function testRelativeLinksForStoreAPIOutput(): void
-    {
-        $request = new Request();
-        $request->server->set('REQUEST_URI', '/store-api/category/0192245eafa37a74b72aa75db0146f2e');
-        $request->attributes->set('_route', 'store-api.category.detail');
-        $request->attributes->set('_routeScope', ['store-api']);
-        $context = new Context(new ShopApiSource('test'));
-        $salesChannelContext = $this->createMock(SalesChannelContext::class);
-
-        $pages = $this->getCmsPagesWithTestData();
-        $event = $this->createMock(CmsPageLoadedEvent::class);
-        $event->method('getRequest')->willReturn($request);
-        $event->method('getResult')->willReturn($pages);
-        $event->method('getSalesChannelContext')->willReturn($salesChannelContext);
-        $event->method('getContext')->willReturn($context);
-
-        $this->subscriber->relativeLinksForStoreAPIOutput($event);
-        $firstPage = $pages->first();
-
-        static::assertCount(2, $pages);
-        static::assertSame($this->getTransformedContent(), $firstPage?->getSections()?->first()?->getBlocks()?->first()?->getSlots()?->first()?->getTranslated()['config']['content']['value']);
-        static::assertEquals(new ArrayStruct(
-            [
-                'content' => [
-                    'value' => $this->getOriginalStaticContent(),
-                    'source' => 'static',
-                ],
-            ]
-        ), $firstPage->getSections()->first()->getBlocks()->first()->getSlots()->first()->getData());
-    }
-
-    private function getOriginalStaticContent(): string
-    {
-        return '<h2>This page content is testing links in CMS</h2><p>- <a target=\"_self\" href=\"124c71d524604ccbad6042edce3ac799/navigation/01920590471d70e59cdd73d38223d192#\" rel=\"noreferrer noopener\">Category Link</a></p><p>- <a target=\"_self\" href=\"124c71d524604ccbad6042edce3ac799/detail/0192059054e1713ebbb66d1c13cf90ab#\" rel=\"noreferrer noopener\">Product Link</a></p><p>- <a target=\"_self\" href=\"124c71d524604ccbad6042edce3ac799/mediaId/0192058ec7c971ea8069a1f3ba7b3f76#\" rel=\"noreferrer noopener\">File Link</a></p><p>- <a target=\"_self\" href=\"mailto:b.meyer@shopware.com\" rel=\"noreferrer noopener\">E-Mail</a></p><p>- <a target=\"_self\" href=\"tel:+49725133445566\" rel=\"noreferrer noopener\">Phone Number</a></p>';
-    }
-
-    private function getTransformedContent(): string
-    {
-        return '<h2>This page content is testing links in CMS</h2><p>- <a target=\"_self\" href=\"/Garden/Games/\">Category Link</a></p><p>- <a target=\"_self\" href=\"/Enormous-Plastic-Bedder/SW-0192059054e1713ebbb66d1c14858de1\">Product Link</a></p><p>- <a target=\"_self\" href=\"/media/e9/96/12/1726670096/demostore-logo.png?ts=1726670096\">File Link</a></p><p>- <a target=\"_self\" href=\"mailto:b.meyer@shopware.com\">E-Mail</a></p><p>- <a target=\"_self\" href=\"tel:+49725133445566\">Phone Number</a></p>';
-    }
-
-    private function getCmsPagesWithTestData(): CmsPageCollection
-    {
-        $cmsPage1 = (new CmsPageEntity())->assign([
-            'id' => 'page-1',
-            'sections' => new CmsSectionCollection([
-                (new CmsSectionEntity())->assign([
-                    'id' => 'section-1',
-                    'position' => 2,
-                    'blocks' => new CmsBlockCollection([
-                        (new CmsBlockEntity())->assign([
-                            'id' => 'block-1',
-                            'position' => 3,
-                            'slots' => new CmsSlotCollection([
-                                (new CmsSlotEntity())->assign([
-                                    'id' => 'slot-1',
-                                    'slot' => 'left',
-                                    'type' => 'text',
-                                    'config' => [
-                                        'content' => [
-                                            'value' => $this->getOriginalStaticContent(),
-                                            'source' => 'static',
-                                        ]],
-                                    'translated' => [
-                                        'config' => [
-                                            'content' => [
-                                                'value' => $this->getOriginalStaticContent(),
-                                                'source' => 'static',
-                                            ],
-                                        ],
-                                    ],
-                                    'data' => new ArrayStruct(
-                                        [
-                                            'content' => [
-                                                'value' => $this->getOriginalStaticContent(),
-                                                'source' => 'static',
-                                            ],
-                                        ]
-                                    ),
-                                ]),
-                                (new CmsSlotEntity())->assign([
-                                    'id' => 'slot-2',
-                                    'slot' => 'right',
-                                    'type' => 'foo',
-                                    'translated' => [
-                                        'config' => ['Config'],
-                                    ],
-                                ]),
-                                (new CmsSlotEntity())->assign([
-                                    'id' => 'slot-3',
-                                    'slot' => 'content',
-                                    'type' => 'foo',
-                                ]),
-                            ]),
-                        ]),
-                        (new CmsBlockEntity())->assign([
-                            'id' => 'block-2',
-                            'position' => 1,
-                            'slots' => new CmsSlotCollection([
-                                (new CmsSlotEntity())->assign([
-                                    'id' => 'slot-1',
-                                    'slot' => 'content',
-                                    'type' => 'foo',
-                                    'config' => ['translated' => '0'],
-                                ]),
-                            ]),
-                        ]),
-                        (new CmsBlockEntity())->assign([
-                            'id' => 'block-3',
-                            'position' => 2,
-                        ]),
-                    ]),
-                ]),
-                (new CmsSectionEntity())->assign([
-                    'id' => 'section-2',
-                    'position' => 1,
-                ]),
-            ]),
-        ]);
-
-        $cmsPage2 = (new CmsPageEntity())->assign([
-            'id' => 'page-2',
-        ]);
-
-        return new CmsPageCollection([$cmsPage1, $cmsPage2]);
-    }
-}
diff --git a/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php b/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php
index b7688202c9a..5d2a0e0cd5e 100644
--- a/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php
+++ b/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php
@@ -5,6 +5,8 @@
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
+use Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface;
+use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface;
 use Shopware\Core\Framework\Struct\ArrayStruct;
 use Shopware\Core\Framework\Struct\Struct;
 use Shopware\Core\Framework\Test\TestCaseHelper\CallableClass;
@@ -27,12 +29,20 @@ class StoreApiResponseListenerTest extends TestCase
 {
     private StructEncoder&MockObject $encoder;
 
+    private MediaUrlPlaceholderHandlerInterface&MockObject $mediaUrlPlaceholderHandler;
+
+    private SeoUrlPlaceholderHandlerInterface&MockObject $seoUrlPlaceholderHandler;
+
     private StoreApiResponseListener $listener;
 
     protected function setUp(): void
     {
         $this->encoder = $this->createMock(StructEncoder::class);
-        $this->listener = new StoreApiResponseListener($this->encoder, new EventDispatcher());
+        $this->mediaUrlPlaceholderHandler = $this->createMock(MediaUrlPlaceholderHandlerInterface::class);
+        $this->mediaUrlPlaceholderHandler->method('replace')->willReturnArgument(0);
+        $this->seoUrlPlaceholderHandler = $this->createMock(SeoUrlPlaceholderHandlerInterface::class);
+        $this->seoUrlPlaceholderHandler->method('replace')->willReturnArgument(0);
+        $this->listener = new StoreApiResponseListener($this->encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler);
     }
 
     public function testEncodeEvent(): void
@@ -48,7 +58,9 @@ public function testEncodeEvent(): void
 
         $instance = new StoreApiResponseListener(
             $this->createMock(StructEncoder::class),
-            $dispatcher
+            $dispatcher,
+            $this->seoUrlPlaceholderHandler,
+            $this->mediaUrlPlaceholderHandler
         );
 
         $instance->encodeResponse(new ResponseEvent(


Reviews:
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:33:34Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:53:36Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:55:30Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:56:37Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T15:07:49Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T06:47:34Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T06:49:56Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T07:52:29Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T07:53:10Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T08:13:13Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:00:02Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:05:23Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:16:48Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:18:10Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T12:43:36Z
user: login: MartinKrzykawski
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T07:10:29Z
user: login: MartinKrzykawski
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T07:47:30Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T08:43:32Z
user: login: mitelg
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-14T08:49:11Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:13:37Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:15:20Z
user: login: keulinho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:37:10Z
user: login: MartinKrzykawski
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T05:53:00Z
user: login: mitelg
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T06:59:54Z
user: login: BrocksiNet
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T12:15:34Z

Review comments:
diff_hunk: @@ -299,4 +306,23 @@ private function addVisibilityCounts(string $rootId, int $rootLevel, int $depth,
             }
         }
     }
+
+    private function setSeoUrlToInternalLink(CategoryCollection $categories, SalesChannelContext $context): void
+    {
+        foreach ($categories as $category) {
+            if ($category->getType() === CategoryDefinition::TYPE_LINK
+                && $category->getLinkType() !== CategoryDefinition::LINK_TYPE_EXTERNAL) {
+                if ($category->getLinkType() === 'landing_page' && $category->getInternalLink()) {
path: src/Core/Content/Category/SalesChannel/NavigationRoute.php
user: login: keulinho
body: why is this case needed? esp for landing pages we want to generate the SEO link
created_at: 2025-04-08T14:33:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,134 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => 'link',
+                'linkType' => 'landing_page',
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/navigation/footer-navigation/footer-navigation',
+                [
+                    'includes' => [
+                        'category' => ['id', 'name', 'type', 'linkType', 'internalLink'],
+                    ],
+                ],
+                [],
+                ['HTTP_SW-INCLUDE-SEO-URLS' => 'true']
+            );
+
+        $response = json_decode($this->getResponseContent(), true, 512, \JSON_THROW_ON_ERROR);
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === 'landing_page') {
+                static::assertStringContainsString('/test-landing-page', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $this->getContainer()->get('product.repository')->create([
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: please use `\Shopware\Core\Content\Test\Product\ProductBuilder` instead
created_at: 2025-04-08T14:53:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,134 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: all three generated test cases are valuable, but none of them has a SEO URL configured for the entity in question, so please add at least three more test cases where there is a SEO URL set up for those entities

also try to keep the tests lean, maybe we can use a data provider or something to make it easier to understand and read

created_at: 2025-04-08T14:55:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,134 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => 'link',
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: please use the type and linkType consts defined in CategoryDefinition instead of magic strings in this whole test class
created_at: 2025-04-08T14:56:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,175 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    /**
+     * @return array<string, array{linkType: string, categoryId: string, targetId: ?string, expectedUrlPart: string, createEntity: bool, preConfiguredSeoUrl: bool}>
+     */
+    public static function internalLinkSeoUrlDataProvider(): array
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: ok hold on a second the data provider is definitely not easier to understand then multiple tests, can you go back to having one test method per link type and in every test method first test the result without a configured SEO URL , then add the SEO URL and check again that the response changed as expected?

With that extract common code you need in all test methods to provide methods instead of copying the code to every test method
created_at: 2025-04-08T15:07:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+---
+title: Fix SEO URLs for landing pages in footer navigation
+issue: NEXT-3784
path: changelog/_unreleased/2025-04-08-fix-seo-urls-for-landing-pages-in-footer-navigation.md
user: login: keulinho
body: please just use the GH issue number in the format #3784
created_at: 2025-04-09T06:47:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,180 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    /**
+     * Helper method to create a pre-configured SEO URL for an entity
+     */
+    private function createSeoUrl(string $routeName, string $pathInfo, string $seoPathInfo, string $entityId): void
+    {
+        $this->getContainer()->get('seo_url.repository')->create([
+            [
+                'id' => Uuid::randomHex(),
+                'salesChannelId' => $this->ids->get('sales-channel'),
+                'routeName' => $routeName,
+                'pathInfo' => $pathInfo,
+                'seoPathInfo' => $seoPathInfo,
+                'isCanonical' => true,
+                'foreignKey' => $entityId,
+            ],
+        ], Context::createDefaultContext());
+    }
+
+    /**
+     * Helper method to request the footer navigation with SEO URLs
+     */
+    private function requestFooterNavigationWithSeoUrls(): array
+    {
+        $this->browser
+            ->request(
+                'POST',
+                '/store-api/navigation/footer-navigation/footer-navigation',
+                [
+                    'includes' => [
+                        'category' => ['id', 'name', 'type', 'linkType', 'internalLink'],
+                    ],
+                ],
+                [],
+                ['HTTP_SW-INCLUDE-SEO-URLS' => 'true']
+            );
+
+        return json_decode($this->getResponseContent(), true, 512, \JSON_THROW_ON_ERROR);
+    }
+
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/test-landing-page', $category['internalLink']);
+                $originalUrl = $category['internalLink'];
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: please compare with the static string you used in the setup of the entity
created_at: 2025-04-09T06:49:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,188 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        $originalUrl = null;
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: the checks to original url can be removed as we compare now with the exact string we expect in both cases
created_at: 2025-04-09T07:52:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +242,188 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        $originalUrl = null;
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['internalLink']);
+                $originalUrl = $category['internalLink'];
+            }
+        }
+        static::assertNotNull($originalUrl, 'Could not find category with landing page link');
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['internalLink']);
+                static::assertNotEquals($originalUrl, $category['internalLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new \Shopware\Core\Content\Test\Product\ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category4'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        $originalUrl = null;
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/detail/' . $productId, $category['internalLink']);
+                $originalUrl = $category['internalLink'];
+            }
+        }
+        static::assertNotNull($originalUrl, 'Could not find category with product link');
+
+        $this->createSeoUrl(
+            'frontend.detail.page',
+            '/detail/' . $productId,
+            'custom-product-url',
+            $productId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/custom-product-url', $category['internalLink']);
+                static::assertNotEquals($originalUrl, $category['internalLink']);
+            }
+        }
+    }
+
+    public function testCategoryInternalLinkHasSeoUrl(): void
+    {
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category'),
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        $originalUrl = null;
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertNotEmpty($category['internalLink']);
+                $originalUrl = $category['internalLink'];
+            }
+        }
+        static::assertNotNull($originalUrl, 'Could not find category with category link');
+
+        $this->createSeoUrl(
+            'frontend.navigation.page',
+            '/navigation/' . $this->ids->get('category'),
+            'custom-category-url',
+            $this->ids->get('category')
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertStringContainsString('/custom-category-url', $category['internalLink']);
+                static::assertNotEquals($originalUrl, $category['internalLink']);
+            }
+        }
+    }
+
+    /**
+     * Helper method to create a pre-configured SEO URL for an entity
+     */
+    private function createSeoUrl(string $routeName, string $pathInfo, string $seoPathInfo, string $entityId): void
+    {
+        $this->getContainer()->get('seo_url.repository')->create([
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: use the upsert method here, that should solve the test failure
created_at: 2025-04-09T07:53:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +243,210 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new \Shopware\Core\Content\Test\Product\ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category4'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/detail/' . $productId, $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.detail.page',
+            '/detail/' . $productId,
+            'custom-product-url',
+            $productId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/custom-product-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testCategoryInternalLinkHasSeoUrl(): void
+    {
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category'),
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertNotEmpty($category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.navigation.page',
+            '/navigation/' . $this->ids->get('category'),
+            'custom-category-url',
+            $this->ids->get('category')
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertStringContainsString('/custom-category-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testInternalLinkWithNullPlainUrl(): void
+    {
+        $mockUrlGenerator = $this->createMock(AbstractCategoryUrlGenerator::class);
+        $mockUrlGenerator->method('generate')->willReturn(null);
+
+        $container = $this->getContainer();
+        $container->set('Shopware\Core\Content\Category\Service\CategoryUrlGenerator', $mockUrlGenerator);
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category'),
+            ],
+        ], Context::createDefaultContext());
+
+        $originalLink = 'original-link-value';
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'internalLink' => $originalLink,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertEquals($originalLink, $category['internalLink']);
+            }
+        }
+    }
+
+    /**
+     * Helper method to create a pre-configured SEO URL for an entity
+     */
+    private function createSeoUrl(string $routeName, string $pathInfo, string $seoPathInfo, string $entityId): void
+    {
+        $this->getContainer()->get('seo_url.repository')->upsert([
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: please fetch existing seo url configs by searching for the foreign key and reuse that id if it exists
created_at: 2025-04-09T08:13:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,4 +306,18 @@ private function addVisibilityCounts(string $rootId, int $rootLevel, int $depth,
             }
         }
     }
+
+    private function setSeoUrlToInternalLink(CategoryCollection $categories, SalesChannelContext $context): void
+    {
+        foreach ($categories as $category) {
+            if ($category->getType() === CategoryDefinition::TYPE_LINK
+                && $category->getLinkType() !== CategoryDefinition::LINK_TYPE_EXTERNAL) {
+                $plainUrl = $this->categoryUrlGenerator->generate($category, $context->getSalesChannel());
+                if ($plainUrl !== null) {
path: src/Core/Content/Category/SalesChannel/NavigationRoute.php
user: login: keulinho
body: use an early return here
created_at: 2025-04-09T12:00:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,4 +306,29 @@ private function addVisibilityCounts(string $rootId, int $rootLevel, int $depth,
             }
         }
     }
+
+    private function setSeoUrlToInternalLink(CategoryCollection $categories, SalesChannelContext $context): void
+    {
+        foreach ($categories as $category) {
+            if ($category->getType() !== CategoryDefinition::TYPE_LINK
+                || $category->getLinkType() === CategoryDefinition::LINK_TYPE_EXTERNAL) {
+                continue;
+            }
+
+            $internalLink = $category->getInternalLink();
path: src/Core/Content/Category/SalesChannel/NavigationRoute.php
user: login: keulinho
body: fold this as another condition into the if statement above
created_at: 2025-04-09T12:05:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +246,226 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new \Shopware\Core\Content\Test\Product\ProductBuilder($this->ids, 'TEST-1234');
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: import the product builder class
created_at: 2025-04-09T12:16:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +246,226 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new \Shopware\Core\Content\Test\Product\ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category4'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/detail/' . $productId, $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.detail.page',
+            '/detail/' . $productId,
+            'custom-product-url',
+            $productId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/custom-product-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testCategoryInternalLinkHasSeoUrl(): void
+    {
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category'),
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertNotEmpty($category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.navigation.page',
+            '/navigation/' . $this->ids->get('category'),
+            'custom-category-url',
+            $this->ids->get('category')
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertStringContainsString('/custom-category-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testInternalLinkWithNullPlainUrl(): void
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: remove this test case, it is testing an edge case that should not be tested in a integration test
created_at: 2025-04-09T12:18:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +247,194 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category4'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/detail/' . $productId, $category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.detail.page',
+            '/detail/' . $productId,
+            'custom-product-url',
+            $productId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category4') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/custom-product-url', $category['internalLink']);
+            }
+        }
+    }
+
+    public function testCategoryInternalLinkHasSeoUrl(): void
+    {
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category'),
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertNotEmpty($category['internalLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.navigation.page',
+            '/navigation/' . $this->ids->get('category'),
+            'custom-category-url',
+            $this->ids->get('category')
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertStringContainsString('/custom-category-url', $category['internalLink']);
+            }
+        }
+    }
+
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: keulinho
body: remove the additional empty lines here, that should fix PHP lint
created_at: 2025-04-09T12:43:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -408,6 +408,15 @@ public function setLinkNewTab(?bool $linkNewTab): void
         $this->linkNewTab = $linkNewTab;
     }
 
+    public function shouldOpenInNewTab(): bool
+    {
+        if ($this->type === CategoryDefinition::TYPE_LINK && $this->getTranslation('linkNewTab')) {
path: src/Core/Content/Category/CategoryEntity.php
user: login: MartinKrzykawski
body: Just return `$this->type === CategoryDefinition::TYPE_LINK && $this->getTranslation('linkNewTab')` - it is already a boolean expression :) 
created_at: 2025-04-14T06:23:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+---
+title: Fix SEO URLs for landing pages in footer navigation
+issue: #3784
+author: Devin AI
+author_email: devin-ai-integration[bot]@users.noreply.github.com
+author_github: devin-ai-integration
+---
+# Core
path: changelog/_unreleased/2025-04-08-fix-seo-urls-for-landing-pages-in-footer-navigation.md
user: login: MartinKrzykawski
body: The `CategoryUrlExtension ` deprecation should also be included
created_at: 2025-04-14T06:51:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -66,4 +70,16 @@ public function entityLoaded(EntityLoadedEvent $event): void
             $category->setCmsPageIdSwitched(true);
         }
     }
+
+    /**
+     * @param SalesChannelEntityLoadedEvent<SalesChannelCategoryEntity> $event
+     */
+    public function addSeoLinks(SalesChannelEntityLoadedEvent $event): void
+    {
+        foreach ($event->getEntities() as $category) {
+            $category->assign([
path: src/Core/Content/Category/Subscriber/CategorySubscriber.php
user: login: MartinKrzykawski
body: This assignment could also be part of the entityLoaded function -> then the loop iterations would run only one time and not twice and the signature naming is generic enough to combine them :)
created_at: 2025-04-14T06:56:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +246,192 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['seoLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['seoLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/Test-Product/', $category['seoLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.detail.page',
+            '/detail/' . $productId,
+            'custom-product-url',
+            $productId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/custom-product-url', $category['seoLink']);
+            }
+        }
+    }
+
+    public function testCategoryInternalLinkHasSeoUrl(): void
+    {
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_CATEGORY,
+                'internalLink' => $this->ids->get('category2'),
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertNotEmpty($category['seoLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.navigation.page',
+            '/navigation/' . $this->ids->get('category2'),
+            'custom-category-url',
+            $this->ids->get('category2')
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_CATEGORY) {
+                static::assertStringContainsString('/custom-category-url', $category['seoLink']);
+            }
+        }
+    }
+
+    /**
+     * Helper method to create a pre-configured SEO URL for an entity
+     */
+    private function createSeoUrl(string $routeName, string $pathInfo, string $seoPathInfo, string $entityId): void
+    {
+        $criteria = new Criteria();
+        $criteria->addFilter(new EqualsFilter('foreignKey', $entityId));
+        $criteria->addFilter(new EqualsFilter('routeName', $routeName));
+
+        $existingSeoUrls = $this->getContainer()->get('seo_url.repository')
+            ->search($criteria, Context::createDefaultContext());
+
+        $data = [
+            'salesChannelId' => $this->ids->get('sales-channel'),
+            'routeName' => $routeName,
+            'pathInfo' => $pathInfo,
+            'seoPathInfo' => $seoPathInfo,
+            'isCanonical' => true,
+            'foreignKey' => $entityId,
+        ];
+
+        if ($existingSeoUrls->count() > 0 && $existingSeoUrls->first() !== null) {
+            /** @var SeoUrlEntity $seoUrl */
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: MartinKrzykawski
body: Inline annotations are not required in tests, you can easily confirm that it is a SeoUrlEntity via `static::assertInstanceOf(SeoUrlEntity::class, $seoUrl);`
created_at: 2025-04-14T07:09:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +246,192 @@ public function testInclude(): void
         static::assertArrayNotHasKey('id', $response[0]);
     }
 
+    public function testLandingPageInternalLinkHasSeoUrl(): void
+    {
+        $landingPageId = Uuid::randomHex();
+        $this->getContainer()->get('landing_page.repository')->create([
+            [
+                'id' => $landingPageId,
+                'name' => 'Test Landing Page',
+                'url' => 'test-landing-page',
+                'active' => true,
+                'salesChannels' => [
+                    ['id' => $this->ids->get('sales-channel')],
+                ],
+            ],
+        ], Context::createDefaultContext());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_LANDING_PAGE,
+                'internalLink' => $landingPageId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('test-landing-page', $category['seoLink']);
+            }
+        }
+
+        $this->createSeoUrl(
+            'frontend.landing.page',
+            '/landingPage/' . $landingPageId,
+            'custom-landing-page-url',
+            $landingPageId
+        );
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_LANDING_PAGE) {
+                static::assertStringContainsString('/custom-landing-page-url', $category['seoLink']);
+            }
+        }
+    }
+
+    public function testProductInternalLinkHasSeoUrl(): void
+    {
+        $productId = Uuid::randomHex();
+        $productBuilder = new ProductBuilder($this->ids, 'TEST-1234');
+        $productBuilder->id = $productId;
+        $productBuilder
+            ->name('Test Product')
+            ->price(15, 10)
+            ->visibility($this->ids->get('sales-channel'), ProductVisibilityDefinition::VISIBILITY_ALL)
+            ->active(true);
+
+        $productBuilder->write($this->getContainer());
+
+        $this->getContainer()->get('category.repository')->update([
+            [
+                'id' => $this->ids->get('category3'),
+                'type' => CategoryDefinition::TYPE_LINK,
+                'linkType' => CategoryDefinition::LINK_TYPE_PRODUCT,
+                'internalLink' => $productId,
+            ],
+        ], Context::createDefaultContext());
+
+        $response = $this->requestFooterNavigationWithSeoUrls();
+
+        foreach ($response as $category) {
+            if ($category['id'] === $this->ids->get('category3') && $category['linkType'] === CategoryDefinition::LINK_TYPE_PRODUCT) {
+                static::assertStringContainsString('/Test-Product/', $category['seoLink']);
path: tests/integration/Core/Content/Category/SalesChannel/NavigationRouteTest.php
user: login: MartinKrzykawski
body: You can break out of the loop if the right category was found to reduce the number of iterations
created_at: 2025-04-14T07:10:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+---
+title: Fix SEO URLs for landing pages in footer navigation
+issue: #3784
+author: Devin AI
+author_email: devin-ai-integration[bot]@users.noreply.github.com
+author_github: devin-ai-integration
+---
+# Core
path: changelog/_unreleased/2025-04-08-fix-seo-urls-for-landing-pages-in-footer-navigation.md
user: login: MartinKrzykawski
body: You should also add the information that the extension will be removed and the `SalesChannelCategoryEntity::getSeoLink` method should be used instead.
created_at: 2025-04-14T07:43:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,6 +69,12 @@ public function entityLoaded(EntityLoadedEvent $event): void
 
             // mark cms page as set in the subscriber
             $category->setCmsPageIdSwitched(true);
+
+            if ($isSalesChannelEvent && $category instanceof SalesChannelCategoryEntity) {
path: src/Core/Content/Category/Subscriber/CategorySubscriber.php
user: login: MartinKrzykawski
body: This condition should be the first one in the foreach loop. Otherwise, it might be skipped because of the continue statements in the earlier conditions.
created_at: 2025-04-14T07:46:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,8 +42,15 @@ public static function getSubscribedEvents(): array
     public function entityLoaded(EntityLoadedEvent $event): void
     {
         $salesChannelId = $event instanceof SalesChannelEntityLoadedEvent ? $event->getSalesChannelContext()->getSalesChannelId() : null;
+        $isSalesChannelEvent = $event instanceof SalesChannelEntityLoadedEvent;
 
         foreach ($event->getEntities() as $category) {
+            if ($isSalesChannelEvent && $category instanceof SalesChannelCategoryEntity) {
path: src/Core/Content/Category/Subscriber/CategorySubscriber.php
user: login: MartinKrzykawski
body: The second check is not necessary as you have already confirmed that this is a SalesChannel event
created_at: 2025-04-14T08:43:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,4 +306,33 @@ private function addVisibilityCounts(string $rootId, int $rootLevel, int $depth,
             }
         }
     }
+
+    private function setSeoUrlToInternalLink(CategoryCollection $categories, SalesChannelContext $context): void
path: src/Core/Content/Category/SalesChannel/NavigationRoute.php
user: login: MartinKrzykawski
body: This function has still lines of code that are not covered by unit tests :) 
created_at: 2025-04-14T13:13:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,6 +112,8 @@ class CategoryEntity extends Entity
 
     protected ?string $customEntityTypeId = null;
 
+    protected ?string $seoLink = null;
path: src/Core/Content/Category/CategoryEntity.php
user: login: mitelg
body: why do I need this property here, but also in the SalesChannel entity?
created_at: 2025-04-14T13:15:19Z
author_association: MEMBER
diff_hunk: @@ -112,6 +112,8 @@ class CategoryEntity extends Entity
 
     protected ?string $customEntityTypeId = null;
 
+    protected ?string $seoLink = null;
path: src/Core/Content/Category/CategoryEntity.php
user: login: keulinho
body: need to revert the last few commits, devin was going rogue, I've disabled the integration
created_at: 2025-04-14T14:37:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: devin-ai-integration[bot]
created_at: 2025-04-08T14:19:28Z
body: ### 🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:
- Address comments on this PR. Add "(aside)" to your comment to have me ignore it.
- Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:
- [x] Disable automatic comment and CI monitoring

author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-08T14:20:09Z
body: ## [Codecov](https://app.codecov.io/gh/shopware/shopware/pull/8383?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) Report
Attention: Patch coverage is `50.00000%` with `12 lines` in your changes missing coverage. Please review.
> Project coverage is 53.28%. Comparing base [(`59e82bd`)](https://app.codecov.io/gh/shopware/shopware/commit/59e82bdd2748daa09c03b2d5cb95d02a9cb578fc?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) to head [(`9e5c76b`)](https://app.codecov.io/gh/shopware/shopware/commit/9e5c76b1ad2e4c17fff05962c8fcf571f0095ff4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).
> Report is 49 commits behind head on trunk.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/shopware/shopware/pull/8383?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | Patch % | Lines |
|---|---|---|
| [...rk/Adapter/Twig/Extension/CategoryUrlExtension.php](https://app.codecov.io/gh/shopware/shopware/pull/8383?src=pr&el=tree&filepath=src%2FCore%2FFramework%2FAdapter%2FTwig%2FExtension%2FCategoryUrlExtension.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#diff-c3JjL0NvcmUvRnJhbWV3b3JrL0FkYXB0ZXIvVHdpZy9FeHRlbnNpb24vQ2F0ZWdvcnlVcmxFeHRlbnNpb24ucGhw) | 0.00% | [11 Missing :warning: ](https://app.codecov.io/gh/shopware/shopware/pull/8383?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) |
| [...stem/SalesChannel/Api/StoreApiResponseListener.php](https://app.codecov.io/gh/shopware/shopware/pull/8383?src=pr&el=tree&filepath=src%2FCore%2FSystem%2FSalesChannel%2FApi%2FStoreApiResponseListener.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#diff-c3JjL0NvcmUvU3lzdGVtL1NhbGVzQ2hhbm5lbC9BcGkvU3RvcmVBcGlSZXNwb25zZUxpc3RlbmVyLnBocA==) | 88.88% | [1 Missing :warning: ](https://app.codecov.io/gh/shopware/shopware/pull/8383?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##            trunk    #8383      +/-   ##
==========================================
+ Coverage   53.15%   53.28%   +0.13%     
==========================================
  Files        4709     4709              
  Lines      158983   159077      +94     
  Branches     8734     8739       +5     
==========================================
+ Hits        84505    84764     +259     
+ Misses      71956    71787     -169     
- Partials     2522     2526       +4     
```

| [Flag](https://app.codecov.io/gh/shopware/shopware/pull/8383/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | Coverage Δ | |
|---|---|---|
| [jest-admin](https://app.codecov.io/gh/shopware/shopware/pull/8383/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `60.43% <ø> (+0.02%)` | :arrow_up: |
| [jest-storefront](https://app.codecov.io/gh/shopware/shopware/pull/8383/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `70.83% <ø> (ø)` | |
| [phpunit-migration](https://app.codecov.io/gh/shopware/shopware/pull/8383/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `43.49% <ø> (+0.17%)` | :arrow_up: |
| [phpunit-unit](https://app.codecov.io/gh/shopware/shopware/pull/8383/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `45.65% <50.00%> (+0.19%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/shopware/shopware/pull/8383?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: devin-ai-integration[bot]
created_at: 2025-04-08T14:27:54Z
body: ### 🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:
- Address comments on this PR. Add "(aside)" to your comment to have me ignore it.
- Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:
- [ ] Disable automatic comment and CI monitoring

author_association: CONTRIBUTOR
user: login: BrocksiNet
created_at: 2025-04-14T09:11:36Z
body: Tested and looks good 🙌🏼 🚀 

In my local setup, I removed this subscriber and unit test.
```
src/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriber.php
tests/unit/Core/Content/Cms/Subscriber/CmsLinksForStoreApiSubscriberTest.php
```
We should point to the new subscriber in the changelog.

author_association: CONTRIBUTOR