From 59760c7ae379d5dc00c45890b8ca04698bb1a4a5 Mon Sep 17 00:00:00 2001
From: Benni Mack <benni@typo3.org>
Date: Wed, 4 Sep 2024 10:47:45 +0200
Subject: [PATCH] [FEATURE] Add Event to modify the results of the
 PageTreeRepository
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A new PSR-14 Event "AfterRawPageRowPreparedEvent" is added
to allow to filter out, or remove or re-sort items / children
for the page tree in the TYPO3 Backend.

This is typically the case for e.g. sorting subpages
within a page by e.g. the creation date (news as pages).

Resolves: #104832
Releases: main
Change-Id: Id9597897e06bba94957e50cbcfee69d4c2bb960c
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/85864
Reviewed-by: Jochen Roth <rothjochen@gmail.com>
Tested-by: Jochen Roth <rothjochen@gmail.com>
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Benni Mack <benni@typo3.org>
---
 .../Controller/Page/TreeController.php        |  73 ++++-------
 .../AfterRawPageRowPreparedEvent.php          |  45 +++++++
 .../Tree/Repository/PageTreeRepository.php    |  10 +-
 .../Controller/Page/TreeControllerTest.php    | 118 ++++++++++++++++--
 .../Repository/PageTreeRepositoryTest.php     |  54 ++++++++
 ...tToAlterTheResultsOfPageTreeRepository.rst |  56 +++++++++
 6 files changed, 291 insertions(+), 65 deletions(-)
 create mode 100644 typo3/sysext/backend/Classes/Tree/Repository/AfterRawPageRowPreparedEvent.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/13.3/Feature-104832-PSR-14EventToAlterTheResultsOfPageTreeRepository.rst

diff --git a/typo3/sysext/backend/Classes/Controller/Page/TreeController.php b/typo3/sysext/backend/Classes/Controller/Page/TreeController.php
index 8fae2694c283..90c1e253260c 100644
--- a/typo3/sysext/backend/Classes/Controller/Page/TreeController.php
+++ b/typo3/sysext/backend/Classes/Controller/Page/TreeController.php
@@ -20,6 +20,7 @@ namespace TYPO3\CMS\Backend\Controller\Page;
 use Psr\EventDispatcher\EventDispatcherInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Backend\Attribute\AsController;
 use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent;
 use TYPO3\CMS\Backend\Dto\Tree\Label\Label;
 use TYPO3\CMS\Backend\Dto\Tree\PageTreeItem;
@@ -44,42 +45,33 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
  * Controller providing data to the page tree
  * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
  */
+#[AsController]
 class TreeController
 {
     /**
      * Option to use the nav_title field for outputting in the tree items, set via userTS.
-     *
-     * @var bool
      */
-    protected $useNavTitle = false;
+    protected bool $useNavTitle = false;
 
     /**
      * Option to prefix the page ID when outputting the tree items, set via userTS.
-     *
-     * @var bool
      */
-    protected $addIdAsPrefix = false;
+    protected bool $addIdAsPrefix = false;
 
     /**
      * Option to prefix the domain name of sys_domains when outputting the tree items, set via userTS.
-     *
-     * @var bool
      */
-    protected $addDomainName = false;
+    protected bool $addDomainName = false;
 
     /**
      * Option to add the rootline path above each mount point, set via userTS.
-     *
-     * @var bool
      */
-    protected $showMountPathAboveMounts = false;
+    protected bool $showMountPathAboveMounts = false;
 
     /**
      * A list of pages not to be shown.
-     *
-     * @var array
      */
-    protected $hiddenRecords = [];
+    protected array $hiddenRecords = [];
 
     /**
      * An array of background colors for a branch in the tree, set via userTS.
@@ -91,37 +83,23 @@ class TreeController
 
     /**
      * An array of labels for a branch in the tree, set via userTS.
-     *
-     * @var array
      */
     protected array $labels = [];
 
     /**
      * Contains the state of all items that are expanded.
-     *
-     * @var array
      */
-    protected $expandedState = [];
-
-    /**
-     * Instance of the icon factory, to be used for generating the items.
-     *
-     * @var IconFactory
-     */
-    protected $iconFactory;
+    protected array $expandedState = [];
 
     /**
      * Number of tree levels which should be returned on the first page tree load
-     *
-     * @var int
      */
-    protected $levelsToFetch = 2;
+    protected int $levelsToFetch = 2;
 
     /**
      * When set to true all nodes returend by API will be expanded
-     * @var bool
      */
-    protected $expandAllNodes = false;
+    protected bool $expandAllNodes = false;
 
     /**
      * Used in the record link picker to limit the page tree only to a specific list
@@ -129,20 +107,16 @@ class TreeController
      */
     protected array $alternativeEntryPoints = [];
 
-    protected UriBuilder $uriBuilder;
-
     protected PageTreeRepository $pageTreeRepository;
 
     protected bool $userHasAccessToModifyPagesAndToDefaultLanguage = false;
 
-    /**
-     * Constructor to set up common objects needed in various places.
-     */
-    public function __construct()
-    {
-        $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
-        $this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
-    }
+    public function __construct(
+        protected readonly IconFactory $iconFactory,
+        protected readonly UriBuilder $uriBuilder,
+        protected readonly EventDispatcherInterface $eventDispatcher,
+        protected readonly SiteFinder $siteFinder,
+    ) {}
 
     protected function initializeConfiguration(ServerRequestInterface $request)
     {
@@ -568,7 +542,7 @@ class TreeController
                     $entryPointRecord = $this->pageTreeRepository->getTree($entryPointRecord['uid'], null, $entryPointIds);
                 }
 
-                if (is_array($entryPointRecord) && !empty($entryPointRecord)) {
+                if ($entryPointRecord !== []) {
                     $entryPointRecords[$k] = $entryPointRecord;
                 }
             }
@@ -582,16 +556,13 @@ class TreeController
      */
     protected function getDomainNameForPage(int $pageId): string
     {
-        $domain = '';
-        $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
         try {
-            $site = $siteFinder->getSiteByRootPageId($pageId);
-            $domain = (string)$site->getBase();
-        } catch (SiteNotFoundException $e) {
+            $site = $this->siteFinder->getSiteByRootPageId($pageId);
+            return (string)$site->getBase();
+        } catch (SiteNotFoundException) {
             // No site found
         }
-
-        return $domain;
+        return '';
     }
 
     /**
@@ -678,7 +649,7 @@ class TreeController
                     mountPoint: (int)($item['mountPoint'] ?? 0),
                 );
             },
-            GeneralUtility::makeInstance(EventDispatcherInterface::class)->dispatch(
+            $this->eventDispatcher->dispatch(
                 new AfterPageTreeItemsPreparedEvent($request, $items)
             )->getItems()
         );
diff --git a/typo3/sysext/backend/Classes/Tree/Repository/AfterRawPageRowPreparedEvent.php b/typo3/sysext/backend/Classes/Tree/Repository/AfterRawPageRowPreparedEvent.php
new file mode 100644
index 000000000000..8403c6f36a6c
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Tree/Repository/AfterRawPageRowPreparedEvent.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Tree\Repository;
+
+/**
+ * Listeners to this event will be able to modify a page with the special _children key,
+ * or completely change e.g. a title.
+ */
+final class AfterRawPageRowPreparedEvent
+{
+    public function __construct(
+        private array $rawPage,
+        private readonly int $workspaceId
+    ) {}
+
+    public function getRawPage(): array
+    {
+        return $this->rawPage;
+    }
+
+    public function setRawPage(array $rawPage): void
+    {
+        $this->rawPage = $rawPage;
+    }
+
+    public function getWorkspaceId(): int
+    {
+        return $this->workspaceId;
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php b/typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php
index e7f31db08314..c646209829a4 100644
--- a/typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php
+++ b/typo3/sysext/backend/Classes/Tree/Repository/PageTreeRepository.php
@@ -17,6 +17,7 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Backend\Tree\Repository;
 
+use Psr\EventDispatcher\EventDispatcherInterface;
 use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Database\Connection;
@@ -67,6 +68,8 @@ class PageTreeRepository
 
     protected ?string $additionalWhereClause = null;
 
+    protected EventDispatcherInterface $eventDispatcher;
+
     /**
      * @param int $workspaceId the workspace ID to be checked for.
      * @param array $additionalFieldsToQuery an array with more fields that should be accessed.
@@ -111,6 +114,8 @@ class PageTreeRepository
         ], $additionalFieldsToQuery);
         $this->additionalQueryRestrictions = $additionalQueryRestrictions;
 
+        // @todo: use DI in the future
+        $this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
         $this->quotedFields = GeneralUtility::makeInstance(ConnectionPool::class)
             ->getQueryBuilderForTable('pages')
             ->quoteIdentifiersForSelect($this->fields);
@@ -478,6 +483,9 @@ class PageTreeRepository
     {
         $page['_children'] = $groupedAndSortedPagesByPid[(int)$page['uid']] ?? [];
         ksort($page['_children']);
+
+        $event = $this->eventDispatcher->dispatch(new AfterRawPageRowPreparedEvent($page, $this->currentWorkspace));
+        $page = $event->getRawPage();
         foreach ($page['_children'] as &$child) {
             $this->addChildrenToPage($child, $groupedAndSortedPagesByPid);
         }
@@ -757,8 +765,6 @@ class PageTreeRepository
 
     /**
      * Group pages by parent page and sort pages based on sorting property
-     *
-     * @param array $groupedAndSortedPagesByPid
      */
     protected function groupAndSortPages(array $pages, array $groupedAndSortedPagesByPid = []): array
     {
diff --git a/typo3/sysext/backend/Tests/Functional/Controller/Page/TreeControllerTest.php b/typo3/sysext/backend/Tests/Functional/Controller/Page/TreeControllerTest.php
index f50e4a4f1972..cd6e748fd605 100644
--- a/typo3/sysext/backend/Tests/Functional/Controller/Page/TreeControllerTest.php
+++ b/typo3/sysext/backend/Tests/Functional/Controller/Page/TreeControllerTest.php
@@ -19,9 +19,11 @@ namespace TYPO3\CMS\Backend\Tests\Functional\Controller\Page;
 
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
+use Psr\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\DependencyInjection\Container;
 use TYPO3\CMS\Backend\Controller\Event\AfterPageTreeItemsPreparedEvent;
 use TYPO3\CMS\Backend\Controller\Page\TreeController;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Backend\Tests\Functional\Tree\Repository\Fixtures\Tree\NormalizeTreeTrait;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Context\Context;
@@ -29,7 +31,9 @@ use TYPO3\CMS\Core\Context\WorkspaceAspect;
 use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
 use TYPO3\CMS\Core\Http\ServerRequest;
 use TYPO3\CMS\Core\Http\Uri;
+use TYPO3\CMS\Core\Imaging\IconFactory;
 use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
+use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
 use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory;
 use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter;
@@ -61,7 +65,7 @@ final class TreeControllerTest extends FunctionalTestCase
             $factory = DataHandlerFactory::fromYamlFile($scenarioFile);
             $writer = DataHandlerWriter::withBackendUser($this->backendUser);
             $writer->invokeFactory($factory);
-            static::failIfArrayIsNotEmpty($writer->getErrors());
+            self::failIfArrayIsNotEmpty($writer->getErrors());
         }, function () {
             $this->backendUser = $this->setUpBackendUser(1);
             $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->createFromUserPreferences($this->backendUser);
@@ -74,7 +78,16 @@ final class TreeControllerTest extends FunctionalTestCase
     #[Test]
     public function getAllEntryPointPageTrees(): void
     {
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees');
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -189,7 +202,16 @@ final class TreeControllerTest extends FunctionalTestCase
     public function getAllEntryPointPageTreesWithRootPageAsMountPoint(): void
     {
         $this->backendUser->setWebMounts([0, 7000]);
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees');
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -301,7 +323,16 @@ final class TreeControllerTest extends FunctionalTestCase
     #[Test]
     public function getAllEntryPointPageTreesWithSearch(): void
     {
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees', 0, 'Groups');
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -345,7 +376,16 @@ final class TreeControllerTest extends FunctionalTestCase
     #[Test]
     public function getSubtreeForAccessiblePage(): void
     {
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees', 1200);
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -377,7 +417,16 @@ final class TreeControllerTest extends FunctionalTestCase
     #[Test]
     public function getSubtreeForNonAccessiblePage(): void
     {
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees', 1510);
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -390,7 +439,16 @@ final class TreeControllerTest extends FunctionalTestCase
     #[Test]
     public function getSubtreeForPageOutsideMountPoint(): void
     {
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees', 7000);
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -404,7 +462,16 @@ final class TreeControllerTest extends FunctionalTestCase
     public function getAllEntryPointPageTreesWithMountPointPreservesOrdering(): void
     {
         $this->backendUser->setWebmounts([1210, 1100]);
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees');
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -439,7 +506,16 @@ final class TreeControllerTest extends FunctionalTestCase
         $this->backendUser->workspace = 1;
         $context = $this->get(Context::class);
         $context->setAspect('workspace', new WorkspaceAspect(1));
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees');
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -624,7 +700,16 @@ final class TreeControllerTest extends FunctionalTestCase
         $context = $this->get(Context::class);
         $context->setAspect('workspace', new WorkspaceAspect(1));
         // the record was changed from live "Groups" to "Teams modified" in a workspace
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees', 0, $search);
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -658,7 +743,16 @@ final class TreeControllerTest extends FunctionalTestCase
         $this->backendUser->workspace = 1;
         $context = $this->get(Context::class);
         $context->setAspect('workspace', new WorkspaceAspect(1));
-        $subject = $this->getAccessibleMock(TreeController::class, null);
+        $subject = $this->getAccessibleMock(
+            TreeController::class,
+            null,
+            [
+                $this->get(IconFactory::class),
+                $this->get(UriBuilder::class),
+                $this->get(EventDispatcherInterface::class),
+                $this->get(SiteFinder::class),
+            ]
+        );
         $actual = $subject->_call('getAllEntryPointPageTrees', 1200);
         $keepProperties = array_flip(['uid', 'title', '_children']);
         $actual = $this->sortTreeArray($actual);
@@ -711,7 +805,7 @@ final class TreeControllerTest extends FunctionalTestCase
 
         $request = new ServerRequest(new Uri('https://example.com'));
 
-        (new TreeController())->fetchDataAction($request);
+        $this->get(TreeController::class)->fetchDataAction($request);
 
         self::assertInstanceOf(AfterPageTreeItemsPreparedEvent::class, $afterPageTreeItemsPreparedEvent);
         self::assertEquals($request, $afterPageTreeItemsPreparedEvent->getRequest());
diff --git a/typo3/sysext/backend/Tests/Functional/Tree/Repository/PageTreeRepositoryTest.php b/typo3/sysext/backend/Tests/Functional/Tree/Repository/PageTreeRepositoryTest.php
index 094df4450f13..755648c80087 100644
--- a/typo3/sysext/backend/Tests/Functional/Tree/Repository/PageTreeRepositoryTest.php
+++ b/typo3/sysext/backend/Tests/Functional/Tree/Repository/PageTreeRepositoryTest.php
@@ -19,8 +19,11 @@ namespace TYPO3\CMS\Backend\Tests\Functional\Tree\Repository;
 
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
+use Symfony\Component\DependencyInjection\Container;
 use TYPO3\CMS\Backend\Tests\Functional\Tree\Repository\Fixtures\Tree\NormalizeTreeTrait;
+use TYPO3\CMS\Backend\Tree\Repository\AfterRawPageRowPreparedEvent;
 use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
+use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
 use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
 
 final class PageTreeRepositoryTest extends FunctionalTestCase
@@ -547,4 +550,55 @@ final class PageTreeRepositoryTest extends FunctionalTestCase
         $actual = $this->normalizeTreeArray($actual, $keepProperties);
         self::assertEquals($expectedResult, $actual[0]);
     }
+
+    #[Test]
+    public function afterRawPageRowPreparedEventIsCalled()
+    {
+        $afterRawPageRowPreparedEvent = [];
+        $expectedResult = [
+            'uid' => 1,
+            'title' => 'Home',
+            '_children' => [
+                [
+                    'uid' => 2,
+                    'title' => 'Main Area',
+                    '_children' => [
+                        [
+                            'uid' => 21,
+                            'title' => 'Main Area Sub 2',
+                            '_children' => [
+                                // event listener drops children uid 31
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+        /** @var Container $container */
+        $container = $this->get('service_container');
+        $container->set(
+            'after-raw-page-row-prepared-listener',
+            static function (AfterRawPageRowPreparedEvent $event) use (&$afterRawPageRowPreparedEvent) {
+                $afterRawPageRowPreparedEvent[] = $event;
+                $rawPage = $event->getRawPage();
+                if ($rawPage['uid'] === 21) {
+                    $rawPage['_children'] = [];
+                    $event->setRawPage($rawPage);
+                }
+            }
+        );
+
+        $eventListener = $container->get(ListenerProvider::class);
+        $eventListener->addListener(AfterRawPageRowPreparedEvent::class, 'after-raw-page-row-prepared-listener');
+
+        $pageTreeRepository = new PageTreeRepository(0);
+        $pageTreeRepository->fetchFilteredTree('-30,31', [1], '');
+        $actual = $pageTreeRepository->getTree(1, null, [1]);
+        $actual = $this->sortTreeArray([$actual]);
+        $keepProperties = array_flip(['uid', 'title', '_children']);
+        $actual = $this->normalizeTreeArray($actual, $keepProperties);
+        self::assertEquals($expectedResult, $actual[0]);
+        self::assertCount(4, $afterRawPageRowPreparedEvent);
+    }
 }
diff --git a/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104832-PSR-14EventToAlterTheResultsOfPageTreeRepository.rst b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104832-PSR-14EventToAlterTheResultsOfPageTreeRepository.rst
new file mode 100644
index 000000000000..6497a6cc9750
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104832-PSR-14EventToAlterTheResultsOfPageTreeRepository.rst
@@ -0,0 +1,56 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-104832-1725537890:
+
+==========================================================================
+Feature: #104832 - PSR-14 Event to alter the results of PageTreeRepository
+==========================================================================
+
+See :issue:`104832`
+
+Description
+===========
+
+Up until TYPO3 v9, it was possible to alter the rendering of one of TYPO3's
+superpowers — the page tree in the TYPO3 Backend User Interface.
+
+This was done via a "Hook", but was removed due to the migration towards a
+SVG-based tree rendering.
+
+As the Page Tree Rendering has evolved, and the hook system has been replaced
+in favor of PSR-14 Events, a new event :php:`TYPO3\CMS\Backend\Tree\Repository\AfterRawPageRowPreparedEvent`
+has been introduced.
+
+
+Example
+=======
+
+The event listener class, using the PHP attribute :php:`#[AsEventListener]` for
+registration, will rmoeve any children for displaying for the page with the
+UID 123:
+
+..  code-block:: php
+
+    use TYPO3\CMS\Core\Attribute\AsEventListener;
+    use TYPO3\CMS\Backend\Tree\Repository\AfterRawPageRowPreparedEvent;
+
+    final class MyEventListener
+    {
+        #[AsEventListener]
+        public function __invoke(AfterRawPageRowPreparedEvent $event): void
+        {
+            $rawPage = $event->getRawPage();
+            if ((int)$rawPage['uid'] === 123) {
+                $rawPage['_children'] = [];
+                $event->setRawPage($rawPage);
+            }
+        }
+    }
+
+Impact
+======
+
+Using the new PSR-14 event, it's now possible to modify the populated
+:php:`page` properties or its children records.
+
+.. index:: Backend, PHP-API, ext:backend
-- 
GitLab