Repository: shopware/shopware
Pull request #8133: fix: exclude categories deactivated by events
Author: login: dgrothaus-sw
Labels: name: backport-6.6.x
name: domain/customer-support
description: Deprecated: use `customer-support` label instead
name: backport-6.7.0.0
description: Backport change to 6.7.0.0 branch
Created: 2025-03-31T13:58:21Z | Merged: 2025-04-17T06:51:07Z

Get categories from the repository after getting the raw data from the database to allow event listeners and class decorators to mark categories inactive and exclude them from sitemap.

Follow-up and completion of https://github.com/shopware/shopware/pull/7694


Diff:
diff --git a/changelog/_unreleased/2025-04-11-new-event-for-fetched-category-ids.md b/changelog/_unreleased/2025-04-11-new-event-for-fetched-category-ids.md
new file mode 100644
index 00000000000..3ab6c3bf257
--- /dev/null
+++ b/changelog/_unreleased/2025-04-11-new-event-for-fetched-category-ids.md
@@ -0,0 +1,5 @@
+---
+title: New event for fetched category ids
+---
+# Core
+* Added the event `Shopware\Core\Content\Category\Event\SalesChannelCategoryIdsFetchedEvent` to be fired when category ids are fetched directly from database without using the category repository.
diff --git a/src/Core/Content/Category/CategoryException.php b/src/Core/Content/Category/CategoryException.php
index 53ddafe9e49..078a8710548 100644
--- a/src/Core/Content/Category/CategoryException.php
+++ b/src/Core/Content/Category/CategoryException.php
@@ -13,7 +13,6 @@
 class CategoryException extends HttpException
 {
     public const SERVICE_CATEGORY_NOT_FOUND = 'CHECKOUT__SERVICE_CATEGORY_NOT_FOUND';
-
     public const FOOTER_CATEGORY_NOT_FOUND = 'CHECKOUT__FOOTER_CATEGORY_NOT_FOUND';
     public const AFTER_CATEGORY_NOT_FOUND = 'CONTENT__AFTER_CATEGORY_NOT_FOUND';
 
diff --git a/src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php b/src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
new file mode 100644
index 00000000000..305252641d2
--- /dev/null
+++ b/src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
@@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    /**
+     * @var array<string, string>
+     */
+    private array $categoryIds = [];
+
+    /**
+     * @param list<string> $categoryIds Category ids **must** be provided as hex strings
+     */
+    public function __construct(
+        array $categoryIds,
+        private readonly SalesChannelContext $context
+    ) {
+        foreach ($categoryIds as $categoryId) {
+            $this->categoryIds[$categoryId] = $categoryId;
+        }
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function getIds(): array
+    {
+        return \array_values($this->categoryIds);
+    }
+
+    /**
+     * @param string $categoryId Category ID to check as hex string
+     */
+    public function hasId(string $categoryId): bool
+    {
+        return \array_key_exists($categoryId, $this->categoryIds);
+    }
+
+    /**
+     * @param string $categoryId Category ID to remove from IDs as hex string
+     */
+    public function filterId(string $categoryId): void
+    {
+        unset($this->categoryIds[$categoryId]);
+    }
+
+    public function getSalesChannelContext(): SalesChannelContext
+    {
+        return $this->context;
+    }
+
+    public function getContext(): Context
+    {
+        return $this->context->getContext();
+    }
+}
diff --git a/src/Core/Content/DependencyInjection/sitemap.xml b/src/Core/Content/DependencyInjection/sitemap.xml
index 33805189eff..5cfc8c25ef6 100644
--- a/src/Core/Content/DependencyInjection/sitemap.xml
+++ b/src/Core/Content/DependencyInjection/sitemap.xml
@@ -50,6 +50,7 @@
             <argument type="service" id="Shopware\Core\Content\Category\CategoryDefinition"/>
             <argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\IteratorFactory"/>
             <argument type="service" id="router"/>
+            <argument type="service" id="event_dispatcher"/>
 
             <tag name="shopware.sitemap_url_provider"/>
         </service>
diff --git a/src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php b/src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
index 55708d6bc45..67519e5743a 100644
--- a/src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
+++ b/src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
@@ -6,6 +6,7 @@
 use Doctrine\DBAL\Connection;
 use Shopware\Core\Content\Category\CategoryDefinition;
 use Shopware\Core\Content\Category\CategoryEntity;
+use Shopware\Core\Content\Category\Event\SalesChannelCategoryIdsFetchedEvent;
 use Shopware\Core\Content\Sitemap\Service\ConfigHandler;
 use Shopware\Core\Content\Sitemap\Struct\Url;
 use Shopware\Core\Content\Sitemap\Struct\UrlResult;
@@ -16,6 +17,7 @@
 use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
 use Shopware\Core\Framework\Uuid\Uuid;
 use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\Routing\RouterInterface;
 
 #[Package('discovery')]
@@ -31,7 +33,8 @@ public function __construct(
         private readonly Connection $connection,
         private readonly CategoryDefinition $definition,
         private readonly IteratorFactory $iteratorFactory,
-        private readonly RouterInterface $router
+        private readonly RouterInterface $router,
+        private readonly EventDispatcherInterface $eventDispatcher,
     ) {
     }
 
@@ -54,8 +57,27 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
         }
 
         $keys = FetchModeHelper::keyPair($categories);
+        $autoIncrementIds = array_keys($keys);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
+        // The next offset must be taken from all results before the event can filter any ids out to prevent fetching
+        // the same ids again
+        $nextOffset = array_pop($autoIncrementIds);
+        \assert(\is_int($nextOffset) || $nextOffset === null);
+
+        $categoryIdsFetchedEvent = $this->eventDispatcher->dispatch(
+            new SalesChannelCategoryIdsFetchedEvent(\array_column($categories, 'id'), $context)
+        );
+
+        if (empty($categoryIdsFetchedEvent->getIds())) {
+            return new UrlResult([], $nextOffset);
+        }
+
+        $availableCategories = \array_filter(
+            $categories,
+            fn (array $category) => $categoryIdsFetchedEvent->hasId($category['id'])
+        );
+
+        $seoUrls = $this->getSeoUrls($categoryIdsFetchedEvent->getIds(), 'frontend.navigation.page', $context, $this->connection);
 
         /** @var array<string, array{seo_path_info: string}> $seoUrls */
         $seoUrls = FetchModeHelper::groupUnique($seoUrls);
@@ -63,7 +85,7 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
         $urls = [];
         $url = new Url();
 
-        foreach ($categories as $category) {
+        foreach ($availableCategories as $category) {
             $lastMod = $category['updated_at'] ?: $category['created_at'];
 
             $lastMod = (new \DateTime($lastMod))->format(Defaults::STORAGE_DATE_TIME_FORMAT);
@@ -84,11 +106,6 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
             $urls[] = $newUrl;
         }
 
-        $keys = array_keys($keys);
-
-        $nextOffset = array_pop($keys);
-        \assert(\is_int($nextOffset) || $nextOffset === null);
-
         return new UrlResult($urls, $nextOffset);
     }
 
diff --git a/tests/unit/Core/Content/Category/CategoryExceptionTest.php b/tests/unit/Core/Content/Category/CategoryExceptionTest.php
index 6188760dede..eb9052e02ed 100644
--- a/tests/unit/Core/Content/Category/CategoryExceptionTest.php
+++ b/tests/unit/Core/Content/Category/CategoryExceptionTest.php
@@ -21,7 +21,7 @@ public function testCategoryNotFound(): void
         $exception = CategoryException::categoryNotFound($categoryId);
 
         static::assertInstanceOf(CategoryNotFoundException::class, $exception);
-        static::assertEquals(Response::HTTP_NOT_FOUND, $exception->getStatusCode());
+        static::assertSame(Response::HTTP_NOT_FOUND, $exception->getStatusCode());
     }
 
     public function testServiceCategoryNotFoundForSalesChannel(): void
@@ -30,10 +30,10 @@ public function testServiceCategoryNotFoundForSalesChannel(): void
 
         $exception = CategoryException::serviceCategoryNotFoundForSalesChannel($salesChannelName);
 
-        static::assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
-        static::assertEquals(CategoryException::SERVICE_CATEGORY_NOT_FOUND, $exception->getErrorCode());
-        static::assertEquals('Service category, for sales channel sales-channel-name, is not set', $exception->getMessage());
-        static::assertEquals(['salesChannelName' => $salesChannelName], $exception->getParameters());
+        static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
+        static::assertSame(CategoryException::SERVICE_CATEGORY_NOT_FOUND, $exception->getErrorCode());
+        static::assertSame('Service category, for sales channel sales-channel-name, is not set', $exception->getMessage());
+        static::assertSame(['salesChannelName' => $salesChannelName], $exception->getParameters());
     }
 
     public function testFooterCategoryNotFoundForSalesChannel(): void
@@ -42,18 +42,18 @@ public function testFooterCategoryNotFoundForSalesChannel(): void
 
         $exception = CategoryException::footerCategoryNotFoundForSalesChannel($salesChannelName);
 
-        static::assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
-        static::assertEquals(CategoryException::FOOTER_CATEGORY_NOT_FOUND, $exception->getErrorCode());
-        static::assertEquals('Footer category, for sales channel sales-channel-name, is not set', $exception->getMessage());
-        static::assertEquals(['salesChannelName' => $salesChannelName], $exception->getParameters());
+        static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
+        static::assertSame(CategoryException::FOOTER_CATEGORY_NOT_FOUND, $exception->getErrorCode());
+        static::assertSame('Footer category, for sales channel sales-channel-name, is not set', $exception->getMessage());
+        static::assertSame(['salesChannelName' => $salesChannelName], $exception->getParameters());
     }
 
     public function testAfterCategoryNotFound(): void
     {
         $exception = CategoryException::afterCategoryNotFound();
 
-        static::assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
-        static::assertEquals(CategoryException::AFTER_CATEGORY_NOT_FOUND, $exception->getErrorCode());
-        static::assertEquals('Category to insert after not found.', $exception->getMessage());
+        static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
+        static::assertSame(CategoryException::AFTER_CATEGORY_NOT_FOUND, $exception->getErrorCode());
+        static::assertSame('Category to insert after not found.', $exception->getMessage());
     }
 }
diff --git a/tests/unit/Core/Content/Sitemap/Provider/CategoryUrlProviderTest.php b/tests/unit/Core/Content/Sitemap/Provider/CategoryUrlProviderTest.php
index cd6ad1d894b..f499028cc98 100644
--- a/tests/unit/Core/Content/Sitemap/Provider/CategoryUrlProviderTest.php
+++ b/tests/unit/Core/Content/Sitemap/Provider/CategoryUrlProviderTest.php
@@ -10,6 +10,7 @@
 use PHPUnit\Framework\TestCase;
 use Shopware\Core\Content\Category\CategoryDefinition;
 use Shopware\Core\Content\Category\CategoryEntity;
+use Shopware\Core\Content\Category\Event\SalesChannelCategoryIdsFetchedEvent;
 use Shopware\Core\Content\Product\ProductEntity;
 use Shopware\Core\Content\Sitemap\Provider\CategoryUrlProvider;
 use Shopware\Core\Content\Sitemap\Service\ConfigHandler;
@@ -20,9 +21,11 @@
 use Shopware\Core\Framework\Log\Package;
 use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
 use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
 use Shopware\Core\Test\Generator;
 use Shopware\Core\Test\Stub\Framework\IdsCollection;
 use Shopware\Core\Test\TestDefaults;
+use Symfony\Component\EventDispatcher\EventDispatcher;
 use Symfony\Component\Routing\RouterInterface;
 
 /**
@@ -42,8 +45,14 @@ class CategoryUrlProviderTest extends TestCase
 
     private readonly RouterInterface&MockObject $router;
 
+    private readonly EventDispatcher&MockObject $dispatcher;
+
     private readonly IdsCollection $ids;
 
+    private int $categoryResultIncrement;
+
+    private (QueryBuilder&MockObject)|null $queryBuilder = null;
+
     protected function setUp(): void
     {
         $this->configHandler = $this->createMock(ConfigHandler::class);
@@ -52,8 +61,8 @@ protected function setUp(): void
         $this->iteratorFactory = $this->createMock(IteratorFactory::class);
         $this->router = $this->createMock(RouterInterface::class);
         $this->ids = new IdsCollection();
-
-        $this->initServices();
+        $this->dispatcher = $this->createMock(EventDispatcher::class);
+        $this->categoryResultIncrement = 0;
     }
 
     public function testGetDecorated(): void
@@ -70,8 +79,31 @@ public function testGetName(): void
 
     public function testGetCategoryUrls(): void
     {
+        $categoryResult1 = $this->createCategoryResult();
+        $categoryResult2 = $this->createCategoryResult();
+        $queryResult = new Result(
+            new ArrayResult(
+                array_keys($categoryResult1),
+                [
+                    array_values($categoryResult1),
+                    array_values($categoryResult2),
+                ]
+            ),
+            $this->connection
+        );
+        $this->initServices($queryResult);
+        static::assertNotNull($this->queryBuilder);
         $context = Generator::generateSalesChannelContext();
 
+        $event = $this->createSalesChannelCategoryIdsFetchedEvent(
+            \array_column([$categoryResult1, $categoryResult2], 'id'),
+            $context
+        );
+        $this->dispatcher
+            ->expects($this->once())
+            ->method('dispatch')
+            ->willReturn($event);
+
         $provider = $this->getCategoryUrlProvider();
         $urlResult = $provider->getUrls($context, 100, 50);
 
@@ -89,8 +121,133 @@ public function testGetCategoryUrls(): void
         static::assertSame('category/2/detail', $url->getLoc());
     }
 
-    private function initServices(): void
+    public function testGetCategoryUrlsReturnsEmptyResult(): void
+    {
+        $categoryRowNames = array_keys($this->createCategoryResult());
+        $queryResult = new Result(
+            new ArrayResult($categoryRowNames, []),
+            $this->connection
+        );
+        $this->initServices($queryResult);
+        static::assertNotNull($this->queryBuilder);
+        $context = Generator::generateSalesChannelContext();
+
+        $provider = $this->getCategoryUrlProvider();
+        $urlResult = $provider->getUrls($context, 100, 50);
+
+        $urls = $urlResult->getUrls();
+        static::assertCount(0, $urls);
+    }
+
+    public function testGetCategoryUrlsHasNoRestrictiveWhereConditionsBecauseGetExcludedCategoryIdsReturnedEmptyResult(): void
     {
+        $categoryRowNames = array_keys($this->createCategoryResult());
+        $queryResult = new Result(
+            new ArrayResult($categoryRowNames, []),
+            $this->connection
+        );
+        $this->initServices($queryResult, []);
+        static::assertNotNull($this->queryBuilder);
+        $this->configHandler->method('get')->willReturn([]);
+        $context = Generator::generateSalesChannelContext();
+
+        $provider = $this->getCategoryUrlProvider();
+
+        $this->queryBuilder
+            ->method('andWhere')
+            ->willReturnCallback(function ($parameter) {
+                $this->assertNotEquals(
+                    '`category`.id NOT IN (:categoryIds)',
+                    $parameter,
+                    'andWhere should never be called with category ID exclusion'
+                );
+
+                return $this->queryBuilder;
+            });
+
+        $provider->getUrls($context, 100, 50);
+    }
+
+    public function testExcludeFilteredCategories(): void
+    {
+        $categoryResult1 = $this->createCategoryResult();
+        $categoryResult2 = $this->createCategoryResult();
+        $queryResult = new Result(
+            new ArrayResult(
+                array_keys($categoryResult1),
+                [
+                    array_values($categoryResult1),
+                    array_values($categoryResult2),
+                ]
+            ),
+            $this->connection
+        );
+        $this->initServices($queryResult);
+        static::assertNotNull($this->queryBuilder);
+        $context = Generator::generateSalesChannelContext();
+
+        $event = $this->createSalesChannelCategoryIdsFetchedEvent(
+            \array_column([$categoryResult1, $categoryResult2], 'id'),
+            $context,
+            [$categoryResult1['id']]
+        );
+        $this->dispatcher
+            ->expects($this->once())
+            ->method('dispatch')
+            ->willReturn($event);
+
+        $provider = $this->getCategoryUrlProvider();
+        $urlResult = $provider->getUrls($context, 100, 50);
+
+        $urls = $urlResult->getUrls();
+        static::assertCount(1, $urls);
+
+        $url = array_shift($urls);
+        static::assertInstanceOf(Url::class, $url);
+        static::assertSame($this->ids->get('category-2'), $url->getIdentifier());
+        static::assertSame('category/2/detail', $url->getLoc());
+    }
+
+    public function testReturnNextOffsetIfAllCategoriesFiltered(): void
+    {
+        $categoryResult1 = $this->createCategoryResult();
+        $categoryResult2 = $this->createCategoryResult();
+        $queryResult = new Result(
+            new ArrayResult(
+                array_keys($categoryResult1),
+                [
+                    array_values($categoryResult1),
+                    array_values($categoryResult2),
+                ]
+            ),
+            $this->connection
+        );
+        $this->initServices($queryResult);
+        static::assertNotNull($this->queryBuilder);
+        $context = Generator::generateSalesChannelContext();
+
+        $categoryIds = \array_column([$categoryResult1, $categoryResult2], 'id');
+        $event = $this->createSalesChannelCategoryIdsFetchedEvent($categoryIds, $context, $categoryIds);
+        $this->dispatcher
+            ->expects($this->once())
+            ->method('dispatch')
+            ->willReturn($event);
+
+        $provider = $this->getCategoryUrlProvider();
+        $urlResult = $provider->getUrls($context, 100, 50);
+
+        $urls = $urlResult->getUrls();
+        static::assertCount(0, $urls);
+        static::assertSame(2, $urlResult->getNextOffset());
+    }
+
+    /**
+     * @param array<array{resource: class-string, salesChannelId: string, identifier: string}>|null $excludedUrls
+     */
+    private function initServices(
+        Result $categoryQueryResult,
+        ?array $excludedUrls = null,
+    ): void {
         $this->connection->method('fetchAllAssociative')->willReturn([
             [
                 'foreign_key' => $this->ids->get('category-1'),
@@ -100,40 +257,48 @@ private function initServices(): void
 
         $this->router->method('generate')->willReturn('category/2/detail');
 
-        $queryBuilder = $this->createMock(QueryBuilder::class);
-        $queryBuilder->method('executeQuery')->willReturn(new Result(
-            new ArrayResult(
-                [
-                    'increment',
-                    'id',
-                    'created_at',
-                    'updated_at',
-                ],
-                [
-                    [
-                        1,
-                        $this->ids->get('category-1'),
-                        '2021-01-01 00:00:00',
-                        null,
-                    ],
-                    [
-                        2,
-                        $this->ids->get('category-2'),
-                        '2021-01-01 00:00:00',
-                        null,
-                    ],
-                ]
-            ),
-            $this->connection
-        ));
+        $this->queryBuilder = $this->createMock(QueryBuilder::class);
+        $this->queryBuilder->method('executeQuery')->willReturn($categoryQueryResult);
 
         $query = $this->createMock(IterableQuery::class);
-        $query->method('getQuery')->willReturn($queryBuilder);
+        $query->method('getQuery')->willReturn($this->queryBuilder);
 
-        $this->iteratorFactory->method('createIterator')
-            ->willReturn($query);
+        $this->iteratorFactory->method('createIterator')->willReturn($query);
+        $this->configHandler->method('get')
+            ->willReturn($excludedUrls ?? $this->getDefaultExcludedUrls());
+    }
 
-        $this->configHandler->method('get')->willReturn([
+    private function getCategoryUrlProvider(): CategoryUrlProvider
+    {
+        return new CategoryUrlProvider(
+            $this->configHandler,
+            $this->connection,
+            $this->definition,
+            $this->iteratorFactory,
+            $this->router,
+            $this->dispatcher
+        );
+    }
+
+    /**
+     * @return array{increment: int, id: string, created_at: string, updated_at: ?string}
+     */
+    private function createCategoryResult(): array
+    {
+        return [
+            'increment' => ++$this->categoryResultIncrement,
+            'id' => $this->ids->get('category-' . $this->categoryResultIncrement),
+            'created_at' => '2021-01-01 00:00:00',
+            'updated_at' => null,
+        ];
+    }
+
+    /**
+     * @return array<array{resource: class-string, salesChannelId: string, identifier: string}>
+     */
+    private function getDefaultExcludedUrls(): array
+    {
+        return [
             [
                 'resource' => CategoryEntity::class,
                 'salesChannelId' => TestDefaults::SALES_CHANNEL,
@@ -149,17 +314,24 @@ private function initServices(): void
                 'salesChannelId' => Uuid::randomHex(),
                 'identifier' => $this->ids->get('product-3'),
             ],
-        ]);
+        ];
     }
 
-    private function getCategoryUrlProvider(): CategoryUrlProvider
-    {
-        return new CategoryUrlProvider(
-            $this->configHandler,
-            $this->connection,
-            $this->definition,
-            $this->iteratorFactory,
-            $this->router
+    /**
+     * @param list<string> $categoryIds
+     * @param list<string> $filterIds
+     */
+    private function createSalesChannelCategoryIdsFetchedEvent(
+        array $categoryIds,
+        SalesChannelContext $context,
+        array $filterIds = []
+    ): SalesChannelCategoryIdsFetchedEvent {
+        $categoryIdsFetchedEvent = new SalesChannelCategoryIdsFetchedEvent(
+            $categoryIds,
+            $context
         );
+        \array_map(fn (string $categoryId) => $categoryIdsFetchedEvent->filterId($categoryId), $filterIds);
+
+        return $categoryIdsFetchedEvent;
     }
 }


Reviews:
user: login: marcelbrode
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T14:11:39Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T15:00:37Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T06:04:27Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:11:29Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T08:32:10Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T11:49:56Z
user: login: MartinKrzykawski
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T07:29:52Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:37:10Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T09:19:37Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T09:23:19Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:53:52Z
user: login: mitelg
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-14T12:16:29Z
user: login: MartinKrzykawski
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:53:39Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:26:56Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:35:18Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:43:20Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:48:04Z
user: login: mitelg
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-15T09:44:40Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:47:33Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T11:17:14Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T13:34:26Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:22:40Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T06:39:22Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T06:40:40Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T06:56:54Z
user: login: mitelg
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T07:43:51Z
user: login: MartinKrzykawski
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:35:23Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:45:25Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:46:33Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:06:26Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T09:19:39Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:24:34Z
user: login: MartinKrzykawski
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:10:29Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T10:31:45Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:57:36Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T11:43:09Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T11:45:35Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T12:40:05Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T14:08:34Z
user: login: mitelg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T15:05:15Z
user: login: dgrothaus-sw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T05:25:05Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T05:49:08Z
user: login: MartinKrzykawski
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T05:49:53Z
user: login: MartinKrzykawski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:13:34Z

Review comments:
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: mitelg
body: why not just introducing an event for the keys? 
created_at: 2025-03-31T15:00:37Z
author_association: MEMBER
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: dgrothaus-sw
body: I'm not sure what you're suggesting. Currently, the categories are fetched directly from database, so any event subscriber or repository decorated won't have the possibility to modify values of the category.
I'd think that introducing a new event will just over-complicate things as categories would behave differently if shown in the shop, accessed by API or put into the sitemap. A consistent experience would appear more beneficial to me.
created_at: 2025-04-01T06:04:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: mitelg
body: the categories are loaded directly from the database without the DAL. I guess, this was made due to performance reasons. 
Now you are loading the categories again via the DAL anyway. and the whole objects, not just the needed IDs. 

you want to exclude some of those IDs, if I got that right. so why not putting them on an event. maybe with all the data, we are fetching here directly from the DB and then let the subscriber decided which IDs to remove. 

I currently do not see, how an additional loading of the categories could help? Especially as only active categories are fetched in the first place and you do the check here again, but how could that have an effect? it would be the same result :thinking: 
created_at: 2025-04-01T08:11:29Z
author_association: MEMBER
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: dgrothaus-sw
body: Let's say that I, as an external developer, add a listener to `sales_channel.category.loaded` to enable or disable categories dynamically based on certain conditions or rules. I would expect the sitemap of that Sales Channel to respect the changed I made creating that listener.

When a new event is introduced, I would have to cover multiple events to achieve the same thing.
Live example: The DynamicAccess extension, which is enabled for all cloud customers, modifies the availability of categories based on rules defined by the customer with that event listener. All areas of Shopware respect those rules, except the sitemap. That event listener that should fire after a category is loaded changes the category to be inactive. That's why the results are not the same.

Regarding the performance: I thought about that too. That's why I kept the initial raw query that checks for the active flag in the database and only load these categories by DAL to allow event listeners to fire and modify the result.

created_at: 2025-04-01T08:32:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: mitelg
body: okay, now I understand where you are coming from :+1: (maybe a link to a GitHub issue or explanation in the description would have helped in first place :wink:) 

keeping the plain SQL and then loading via DAL make no sense imo. 
I would see no reason, why we shouldn't use the DAL in the first place. we can achieve the same result with this. 

Then again, I would wonder why we have plain SQL here and I would assume, that we have an impact on the performance, now that want to load the categories twice.

So making it slow again for everyone, just so it is easier to extend for our own extension, is no valid argument in my eyes :slightly_smiling_face: 

so either you give me some numbers, that the performance impact is so slow, we can neglect it, or we need another solution for this.

please don't get me wrong, I don't mean it in bad way! we just need to think of possible consequences :slightly_smiling_face: 
created_at: 2025-04-01T11:49:56Z
author_association: MEMBER
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: MartinKrzykawski
body: In my opinion, it’s better to implement and dispatch a custom event for this. The DAL events are very generic, and intervening in them doesn’t provide a good developer experience. It also creates issues if you need to manipulate the loaded categories for a specific case, such as the sitemap. External developers wouldn’t have a clear indicator that the dispatched event is related to the sitemap scope. Performance might also be a concern, but even from a developer experience standpoint alone, a dedicated event would be the better approach.
created_at: 2025-04-02T07:29:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: dgrothaus-sw
body: IMHO it's not a sitemap relevant issue. If a developer deactivates a category for a sales channel by a decorator, event listener or plugin, that category should be deactivated everywhere and not everywhere except the sitemap.

But as this is the second request to introduce a new event for this specific event, I will rewrite the code and the plugin to introduce and use a new event.
created_at: 2025-04-02T08:37:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: MartinKrzykawski
body: I see your point, but it doesn’t provide a good developer experience. Additionally, the deeper you go into the technical stack, the more plain SQL queries you’ll encounter that bypass the DAL for performance reasons. There will always be edge cases that generic events can’t fully cover. 🙂
created_at: 2025-04-02T09:19:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: MartinKrzykawski
body: or you replace the plain sql call with a DAL call and you benchmark the performance like telgi mentioned :) 
created_at: 2025-04-02T09:23:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,15 +61,24 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
 
         $keys = FetchModeHelper::keyPair($categories);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: mitelg
body: that is what we agreed on in a call, sorry that we didn't wrote it down here. we would like to know, if changing back to the DAL has a big impact, or if the performance benefit is already happening due to the pagination which was introduced at the same time
created_at: 2025-04-02T12:53:52Z
author_association: MEMBER
diff_hunk: @@ -56,4 +56,13 @@ public function testAfterCategoryNotFound(): void
         static::assertEquals(CategoryException::AFTER_CATEGORY_NOT_FOUND, $exception->getErrorCode());
         static::assertEquals('Category to insert after not found.', $exception->getMessage());
     }
+
+    public function testInvalidCategoryId(): void
+    {
+        $exception = CategoryException::invalidCategoryId('invalid-category-id');
+
+        static::assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
path: tests/unit/Core/Content/Category/CategoryExceptionTest.php
user: login: mitelg
body: please use `assertSame` where possible instead, as it is type safe
created_at: 2025-04-14T12:09:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,100 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
+
+    /**
+     * @var array<string, string>
+     */
+    private array $categoryIds = [];
+
+    /**
+     * @param list<string> $categoryIds
+     */
+    public function __construct(
+        array $categoryIds,
+        private readonly SalesChannelContext $context
+    ) {
+        foreach ($categoryIds as $categoryId) {
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: I am not sure, if we should put so much logic in an event :thinking: 

we should make sure, that the the provided array is a list of hex UUIDs and not more. no magic converting and so on. 

also you have this filtering here. with that we then have two properties with different states. imo, there should only be one property. especially as your not using the filtered IDs. 
created_at: 2025-04-14T12:15:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,5 @@
+---
+title: New event for fetched category ids
+---
+# Core
+* Added the event Listener `Shopware\Core\Content\Category\Event\SalesChannelCategoryIdsFetchedEvent` to be fired when category ids are fetched directly from database without using the category repository.
path: changelog/_unreleased/2025-04-11-new-event-for-fetched-category-ids.md
user: login: MartinKrzykawski
body: It is an event, not an event listener :)
created_at: 2025-04-14T12:30:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
+
+    /**
+     * @var array<string, string>
+     */
+    private array $categoryIds = [];
+
+    /**
+     * @param list<string> $categoryIds
+     */
+    public function __construct(
+        array $categoryIds,
+        private readonly SalesChannelContext $context
+    ) {
+        foreach ($categoryIds as $categoryId) {
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: I agree — just dispatch an event with the UUIDs in hex format. The logic for what happens with the IDs should be handled within the event listener.
created_at: 2025-04-14T12:35:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Exception;
+
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\ShopwareHttpException;
+use Symfony\Component\HttpFoundation\Response;
+
+#[Package('discovery')]
+class InvalidCategoryIdException extends ShopwareHttpException
path: src/Core/Content/Category/Exception/InvalidCategoryIdException.php
user: login: MartinKrzykawski
body: Please cover this class also with unit tests :)
created_at: 2025-04-14T12:39:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -89,8 +121,141 @@ public function testGetCategoryUrls(): void
         static::assertSame('category/2/detail', $url->getLoc());
     }
 
-    private function initServices(): void
+    public function testEmptyUrlResult(): void
+    {
+        $categoryRowNames = array_keys($this->createCategoryResult());
+        $queryResult = new Result(
+            new ArrayResult($categoryRowNames, []),
+            $this->connection
+        );
+        $this->initServices($queryResult);
+        static::assertNotNull($this->queryBuilder);
+        $context = Generator::generateSalesChannelContext();
+
+        $provider = $this->getCategoryUrlProvider();
+        $urlResult = $provider->getUrls($context, 100, 50);
+
+        $urls = $urlResult->getUrls();
+        static::assertCount(0, $urls);
+    }
+
+    public function testEmptyExcludedUrls(): void
path: tests/unit/Core/Content/Sitemap/Provider/CategoryUrlProviderTest.php
user: login: MartinKrzykawski
body: IMO the test functions are too hard to read. They are too dynamic. 

A good unit test should feel like a real test where you provide data and expect data back which you assert afterwards. This test is designed just to run into each single line of code to increase the coverage.





created_at: 2025-04-14T12:49:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -89,8 +121,141 @@ public function testGetCategoryUrls(): void
         static::assertSame('category/2/detail', $url->getLoc());
     }
 
-    private function initServices(): void
+    public function testEmptyUrlResult(): void
path: tests/unit/Core/Content/Sitemap/Provider/CategoryUrlProviderTest.php
user: login: MartinKrzykawski
body: The naming could be improved, you do not write which method is tested, this makes harder to understand the idea behind it. Try testGetUrlsReturnsNothing or testGetUrlsReturnsEmptyUrlResult or something.
created_at: 2025-04-14T12:52:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,4 +56,13 @@ public function testAfterCategoryNotFound(): void
         static::assertEquals(CategoryException::AFTER_CATEGORY_NOT_FOUND, $exception->getErrorCode());
         static::assertEquals('Category to insert after not found.', $exception->getMessage());
     }
+
+    public function testInvalidCategoryId(): void
+    {
+        $exception = CategoryException::invalidCategoryId('invalid-category-id');
+
+        static::assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode());
path: tests/unit/Core/Content/Category/CategoryExceptionTest.php
user: login: dgrothaus-sw
body: I promise to burn into my inner eye to check for comparison whenever I copy from an existing test.
created_at: 2025-04-14T13:26:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
+
+    /**
+     * @var array<string, string>
+     */
+    private array $categoryIds = [];
+
+    /**
+     * @param list<string> $categoryIds
+     */
+    public function __construct(
+        array $categoryIds,
+        private readonly SalesChannelContext $context
+    ) {
+        foreach ($categoryIds as $categoryId) {
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: Ensure by asserting in dev/test environment or better throw an Exception in case of an error?
created_at: 2025-04-14T13:35:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Exception;
+
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\ShopwareHttpException;
+use Symfony\Component\HttpFoundation\Response;
+
+#[Package('discovery')]
+class InvalidCategoryIdException extends ShopwareHttpException
path: src/Core/Content/Category/Exception/InvalidCategoryIdException.php
user: login: dgrothaus-sw
body: Isn't the `testInvalidCategoryId()` test I added to `CategoryExceptionTest` enough? I can, of course, add a more detailed test, if the existing test is not sufficient.
created_at: 2025-04-14T13:43:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
+
+    /**
+     * @var array<string, string>
+     */
+    private array $categoryIds = [];
+
+    /**
+     * @param list<string> $categoryIds
+     */
+    public function __construct(
+        array $categoryIds,
+        private readonly SalesChannelContext $context
+    ) {
+        foreach ($categoryIds as $categoryId) {
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: I would prefer throwing an exception directly — even in production — because passing wrong data into the event is a clear violation of the contract.

Assertions are great for development, but they wouldn’t catch issues in production. Since the event should be clean and simple, I think it’s better to fail fast and loud if something unexpected happens.
created_at: 2025-04-14T13:48:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,6 +27,11 @@ public static function categoryNotFound(string $id): ShopwareHttpException
         return new CategoryNotFoundException($id);
     }
 
+    public static function categoryIdIsNotValidHex(string $categoryId): CategoryIdIsNotValidHexException
+    {
+        return new CategoryIdIsNotValidHexException($categoryId);
path: src/Core/Content/Category/CategoryException.php
user: login: mitelg
body: as we do not catch `CategoryIdIsNotValidHexException` ourselves, please only return an instance of `CategoryException`. have a look a the ADR for more information about what to implement when: https://github.com/shopware/shopware/blob/trunk/adr/2022-02-24-domain-exceptions.md
created_at: 2025-04-15T09:36:45Z
author_association: MEMBER
diff_hunk: @@ -54,16 +58,36 @@ public function getUrls(SalesChannelContext $context, int $limit, ?int $offset =
         }
 
         $keys = FetchModeHelper::keyPair($categories);
+        $autoIncrementIds = array_keys($keys);
 
-        $seoUrls = $this->getSeoUrls(array_values($keys), 'frontend.navigation.page', $context, $this->connection);
+        // The next offset must be taken from all results before the event can filter any ids out to prevent fetching
+        // the same ids again
+        $nextOffset = array_pop($autoIncrementIds);
+        \assert(\is_int($nextOffset) || $nextOffset === null);
+
+        $categoryIdsFetchedEvent = $this->eventDispatcher->dispatch(
+            new SalesChannelCategoryIdsFetchedEvent(\array_column($categories, 'id'), $context),
+            CategoryEvents::SALES_CHANNEL_CATEGORY_IDS_FETCHED_EVENT
path: src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php
user: login: mitelg
body: do we need to provide an event name? isn't the class name enough? 
created_at: 2025-04-15T09:40:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,107 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: I still don't see, why we need that second property
created_at: 2025-04-15T09:41:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,107 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: To easily check which categories were removed. You don't think this is useful? I can remove it if you see no usefulness.
created_at: 2025-04-15T09:47:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,107 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
+
+    /**
+     * @var array<string, string>
+     */
+    private array $filteredIds = [];
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: you are not using it by yourself, so I would stick with the most simply version. if there is a need, it would be no problem to provide that. 

but we always need to have in mind, that every public method is additional API we need to support.
created_at: 2025-04-15T11:17:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,83 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: do we still need the name then? :thinking: 
created_at: 2025-04-15T13:34:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,83 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: What are the advantages and disadvantages of having the name as a constant in the Event vs hardcoding the string into `CategoryEvents` I'll do anything as long as it makes sense.
created_at: 2025-04-15T14:22:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,83 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: that is something of the historical things :see_no_evil: afaik back then, when we were in the early development of SW6 the events needed a name in Symfony. therefore we thought it would be a good idea to collect the event names in constants for every entity. nowadays only the class name is used and needed, and we do not really care about those event constants anymore. 
at least I have no one seeing editing those for a long time :see_no_evil: 

imo, this is okay, as those constants strings are just unnecessary, as we already have a unique name with the FQCN. 

so I would say, we let those event constants alone and do not add something there. instead we should even thinking about deprecating those. 
created_at: 2025-04-16T06:39:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,83 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: one additional thing: we have some generic events, that are named after the entity processed, like `product.written`. those should stay of course
created_at: 2025-04-16T06:40:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,83 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
+{
+    final public const NAME = 'sales_channel.category.keys.fetched';
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: Thanks for clarification! :blue_heart: 
created_at: 2025-04-16T06:56:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: Sorry, I don’t want to block your PR, but just to clarify my last comment:
This event should only carry the already validated category IDs in hex format – nothing more.
The validation logic should live in the service where the event is dispatched (e.g. in src/Core/Content/Sitemap/Provider/CategoryUrlProvider.php):


```
$ids = $this->getCategoryIds();
$this->validateIds($ids);
$this->dispatcher->dispatch(new YourEvent($ids));
```

Including logic inside an event goes against the SOLID principles and clean architecture.
An event should only act as a signal that something happened – it shouldn’t make decisions or contain business logic. 🙈

created_at: 2025-04-16T08:33:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: But how do I ensure that everybody dispatching the event does indeed provide the events in the proper format? If the checks won't happen here but happen on the dispatching side, no listener can ever be sure that the provided strings are what they promise to be. I really struggle to implement a class that accepts just any string and just echos what was provided without any checks that the message that should be transported even is a valid message.

I also don't see any of the SOLID principles being violated. The sole responsibility of the event is to carry category ids. To fulfill that responsibility it has to ensure that the message it carries is valid.
created_at: 2025-04-16T08:45:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body:  @mitelg Do you have an opinion regarding this?
created_at: 2025-04-16T08:46:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: I understand your concern but EventListeners should always expect that the data inside an Event is valid. An Event is not a validator. Then we would always need to validate the data which is provided by an event. 
The responsibility for the validity of the data lies with the calling code - i.e. where the event is created and dispatched. The principle is the "Single Responsibility Principle"
created_at: 2025-04-16T09:06:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: no :grin: I can understand both sides. but I don't know if having validation in an event is a no-go or not :shrug: 
created_at: 2025-04-16T09:19:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Content\Category\CategoryException;
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\Framework\Uuid\Uuid;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: I wouldn't see the need for validation if the event would expect a specific type as the type would be check enough in my opinion. But in case of arrays or other strings I see the requirement to ensure that the data is as expected. But as I don't want to stretch the release of this any longer, I will remove the checks from the event.
created_at: 2025-04-16T09:24:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: The event includes still logic.

Here are my thoughts:

The constructor and name suggest this event’s only job is to transport a list of category IDs for a given context – like a signal: “Hey, here are the fetched category IDs.”

But the class currently does more:
	•	Converts the array to an associative map
	•	Exposes logic: filterId() mutates state, hasId() adds business checks
	•	Introduces behavior that goes beyond data transport

This breaks Single Responsibility, makes the event harder to reason about, and introduces hidden side effects.

Real-world issue:
Imagine a dev writes a listener that reacts to this event and assumes getIds() gives them the original order or exact set of IDs that were fetched. But later, another dev adds a new filterId() call before dispatching — suddenly the listener works with filtered data without knowing it. This breaks expectations and introduces side effects that are hard to track.

Suggestion:
Do validation & transformation in the dispatching service. Let the event carry clean data only :))

The event should look like this:

```
final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
{
    /**
     * @param list<string> $categoryIds
     */
    public function __construct(
        private readonly array $categoryIds,
        private readonly SalesChannelContext $context
    ) {}

    public function getCategoryIds(): array
    {
        return $this->categoryIds;
    }

    public function getSalesChannelContext(): SalesChannelContext
    {
        return $this->context;
    }

    public function getContext(): Context
    {
        return $this->context->getContext();
    }
}
```
created_at: 2025-04-16T10:08:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: but the whole purpose of the event is, that the provided IDs could be modified :thinking: how do you want to achieve that, if this would only be some kind of "notification" event?
created_at: 2025-04-16T10:31:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: > Real-world issue: Imagine a dev writes a listener that reacts to this event and assumes getIds() gives them the original order or exact set of IDs that were fetched. But later, another dev adds a new filterId() call before dispatching — suddenly the listener works with filtered data without knowing it. This breaks expectations and introduces side effects that are hard to track.

This is exactly the intended behaviour. Each listener should have the possibility to reduce the array of ids the listener get from `getIds()`. It doesn't matter if the list has already been filtered or not. Of the list has been filtered already, that's totally fine, as the IDs are not relevant anymore. The event previously had a method to get the filtered IDs in case the information of already filtered IDs would be relevant to the listener, but this has already been removed because it was too much logic. By having a filtered list, it's even better for listeners, as they don't have to handle IDs that would be ignored anyway.

> The event should look like this…

How would the listener be able to tell the dispatching service which category ids to remove from the result? I need a way to tell which IDs are irrelevant. I could rewrite the event to include the `getFilteredIDs()` again, leave the category IDs untouched and the dispatching service would have to do the work itself by reducing the fetched IDs by the filtered IDs itself. But other events allow the mutation of data, as they transport objects. I don't really understand why this event should actively prevent data mutation, when other events allow this.
created_at: 2025-04-16T10:57:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: The logic would be then in the event listener: if this event includes only an array of categoryIds, you could manipulate them then in your specific event listener. If you listen to this event and you call "getIds()" then you get already the reference to the categoryIds array of this event. There you can filter, delete and add new IDs. With the current approach adding new categoryIds would not be possible e.g.
created_at: 2025-04-16T11:43:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: An event does not need logic, the logic should be executed before and after the dispatch :)
created_at: 2025-04-16T11:45:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: I still don't get how the manipulated information gets back to the dispatcher in this scenario. Are you suggesting passing the array as reference to manipulate the array directly in the listener? Or is your suggestion that the dispatching service isn't aware that any listener did anything and the listener itself should do all the work? The approach where the listener does the work wouldn't work for such a generic event as "Look, I just got 10 categories from the database" as the listener wouldn't know that the dispatching service intended to do with it. The information about filtered out category ids somehow must be brought back to the dispatching service and I can't see how this happens in your scenario.
created_at: 2025-04-16T12:40:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: I meant that the array can be manipulated directly in the event listener that listens to your event.
For example:

```
$ids = $event->getIds();
$ids[] = $newId;
unset($ids['foo']);
```

This would update the provided IDs by reference in PHP. The modified data would then be available in the service that dispatched the event — with the logic cleanly placed in the listener, not the event itself.

As an alternative, for more explicitness and better understanding, you could also implement getIds() and setIds() in the event:

```
public function getCategoryIds(): array
{
    return $this->categoryIds;
}

public function setCategoryIds(array $new): void
{
    $this->categoryIds = $new;
}
```

Then, in the event listener:
```
$ids = $event->getCategoryIds();
$ids[] = 'new-id';
$event->setCategoryIds($ids);
```

This keeps the event focused on a single responsibility — transporting data — while leaving business logic and decisions to the listener.

I hope this clarifies my point a bit. Of course, happy to discuss this further or pair up on it tomorrow if you like! :) 
created_at: 2025-04-16T14:08:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: mitelg
body: not tested, but I am pretty sure, that this does not work :grimacing: 

```
$ids = $event->getIds();
$ids[] = $newId;
unset($ids['foo']);
```
arrays are not referenced, but copied by default, afaik
created_at: 2025-04-16T15:05:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: dgrothaus-sw
body: @mitelg is correct here. This does not work. Arrays are passed by value if not stated otherwise. That's why I asked explicitly if you want the event to have the array passed as reference. And passing arrays by reference is a quite archaic approach as this allows for uncontrolled manipulation.
I also don't see any value in a setter, only a threat to manipulate the data. The way it's implemented now, no listener has the possibility to sneak in a category id that was not in the original database result. Both your suggestions would allow any event listener to arbitrary manipulate the array to their likes.
I already didn't feel well removing the check for a valid hex UUID as this allows passing just any data to the listeners and now requires validating the ids on each listeners side as the messenger (Event) does no threat check. But having the Event as an open gateway between third party code and the dispatching service to just pass anything to database queries and requiring validation on each and every side instead of just once in the messenger feels wrong to me. As sender and as receiver I want to be on the safe side to know that the messenger takes care of my message and does not manipulate it in harms way.

I would rather prefer to have the Exception back in the event. After all, the other variables of the Event are called by their proper strict type, too. They don't accept `object` and tell the listener that it probably is gonna be `SalesChannelContext`. It ensures that the listener gets a `SalesChannelContext` by using a strict type instead of `object`. I'd rather this event does the same for the strings passed, as there is no dedicated class to transport a collection of validated database IDs.
created_at: 2025-04-17T05:25:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: Oh okay! I had in my mind that it is possible, i will test it later, sorry for blocking! I will approve this PR that we get it done before eastern.

"And passing arrays by reference is a quite archaic approach as this allows for uncontrolled manipulation.
I also don't see any value in a setter, only a threat to manipulate the data." - manipulation inside an event listener which want to manipulate the payload, that was the idea behind it. With the current solution it is possible too to manipulate the data. I still think the validation part should still be part of the service that dispatches the event because an event is not a validator. 

I will approve it, it is okay for me like it is now but i will post here feedback because i am very interested in if it is really not possible solving it via reference :D 

Good job!
created_at: 2025-04-17T05:49:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+<?php declare(strict_types=1);
+
+namespace Shopware\Core\Content\Category\Event;
+
+use Shopware\Core\Framework\Context;
+use Shopware\Core\Framework\Event\ShopwareEvent;
+use Shopware\Core\Framework\Log\Package;
+use Shopware\Core\System\SalesChannel\SalesChannelContext;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Event that is triggered when category ids are fetched for a sales channel without using the DAL.
+ */
+#[Package('discovery')]
+final class SalesChannelCategoryIdsFetchedEvent extends Event implements ShopwareEvent
path: src/Core/Content/Category/Event/SalesChannelCategoryIdsFetchedEvent.php
user: login: MartinKrzykawski
body: @mitelg you were right 🤝 
created_at: 2025-04-17T06:13:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-31T14:00:25Z
body: ## [Codecov](https://app.codecov.io/gh/shopware/shopware/pull/8133?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 53.28%. Comparing base [(`c2fab41`)](https://app.codecov.io/gh/shopware/shopware/commit/c2fab4150491c740b369e694f630034befff5496?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) to head [(`0ed9566`)](https://app.codecov.io/gh/shopware/shopware/commit/0ed956696ce0f862c138db4db0f0f22ffeba6e54?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware).
> Report is 15 commits behind head on trunk.

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

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


```diff
@@            Coverage Diff             @@
##            trunk    #8133      +/-   ##
==========================================
+ Coverage   53.23%   53.28%   +0.04%     
==========================================
  Files        4710     4710              
  Lines      159125   159142      +17     
  Branches     8739     8739              
==========================================
+ Hits        84709    84793      +84     
+ Misses      71890    71823      -67     
  Partials     2526     2526              
```

| [Flag](https://app.codecov.io/gh/shopware/shopware/pull/8133/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/8133/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `60.42% <ø> (+<0.01%)` | :arrow_up: |
| [jest-storefront](https://app.codecov.io/gh/shopware/shopware/pull/8133/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/8133/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `43.49% <ø> (ø)` | |
| [phpunit-unit](https://app.codecov.io/gh/shopware/shopware/pull/8133/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shopware) | `45.66% <100.00%> (+0.08%)` | :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/8133?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: dgrothaus-sw
created_at: 2025-04-09T09:08:55Z
body: ~~TODO: Tests for new event and filtered sitemap categories~~
author_association: CONTRIBUTOR
user: login: MartinKrzykawski
created_at: 2025-04-16T08:35:43Z
body: It is the last comment, otherwise it looks good 🎉 
author_association: CONTRIBUTOR