Building an Extbase backend module with a page tree 

Suppose visitors comment on conferences, and moderators keep an eye on the comments. If the comments of each site are stored in a folder of that site, and a moderator usually looks after one site, a module with a page tree is a good fit: the moderator selects the site or the folder, and the module lists the comments stored there.

Such a module is the counterpart of the conferences module. It uses the same framework, but the situation is the opposite one. Where a module without a page tree has to configure everything, the selected page can supply most of it:

  • The storagePid can follow the moderator's choice in the page tree.
  • The site of the selected page is known, and with it the languages the moderator could choose from.
  • TYPO3 checks that the moderator may see the selected page before the action runs.

This page shows how such a comment moderation module can be built: registration, configuration that keeps the page tree in charge, a list that includes hidden comments, a language selector, hiding comments with Core's own visibility toggle, and answering them in an Extbase action. Take the parts your module needs.

Registering an Extbase backend module with a page tree 

The simplest way to get the page tree is to register the module below the toplevel module content, which shows it. A module below content inherits the tree, so the registration needs no key for it:

EXT:my_extension/Configuration/Backend/Modules.php
<?php

declare(strict_types=1);

use MyVendor\MyExtension\Controller\CommentModerationController;

return [
  'my_extension_moderation' => [
    // Content modules inherit the page tree of the main module.
    'parent' => 'content',
    'position' => ['after' => 'my_extension_conferences'],
    'access' => 'user',
    'path' => '/module/my-extension/moderation',
    'iconIdentifier' => 'my-extension-moderation-module',
    'labels' => 'my_extension.modules.moderation',
    'extensionName' => 'MyExtension',
    'controllerActions' => [
      CommentModerationController::class => [
        'list',
        'answer',
        'createAnswer',
      ],
    ],
  ],
];
Copied!

A module below another main module can add the page tree with navigationComponent set to '@typo3/backend/tree/page-tree-element' . Core's link validator is registered this way.

The example registers three actions: list shows the comments, answer displays the answer form and createAnswer stores the answer. Hiding a comment does not need an action of its own when the module uses Core's visibility toggle, see Hiding records from an Extbase backend module with Core's visibility toggle.

What the selected page supplies to an Extbase backend module 

When the moderator selects a page, the module is called with the page uid in the id request parameter. Before the controller runs, the backend checks that the moderator may see that page. Without access the module is not called at all.

From the selected page, the module receives:

A controller can read the page uid once, in initializeAction() , and use it in every action:

EXT:my_extension/Classes/Controller/CommentModerationController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Comment;
use MyVendor\MyExtension\Domain\Repository\CommentRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Http\AllowedMethodsTrait;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Extbase\Attribute\IgnoreValidation;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class CommentModerationController extends ActionController
{
  use AllowedMethodsTrait;

  protected const ALL_LANGUAGES = -1;
  protected const TABLE = 'tx_myextension_domain_model_comment';

  protected ?ModuleData $moduleData = null;
  protected ?ModuleTemplate $moduleTemplate = null;
  protected int $pageId = 0;

  public function __construct(
    protected readonly ModuleTemplateFactory $moduleTemplateFactory,
    protected readonly ComponentFactory $componentFactory,
    protected readonly IconFactory $iconFactory,
    protected readonly CommentRepository $commentRepository,
  ) {}

  #[\Override]
  protected function initializeAction(): void
  {
    $this->moduleData = $this->request->getAttribute('moduleData');
    $this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
    $this->pageId = (int)($this->request->getQueryParams()['id'] ?? 0);
  }

  public function listAction(?int $language = null): ResponseInterface
  {
    if ($this->pageId === 0) {
      $this->moduleTemplate->addFlashMessage(
        'Select a page in the page tree to moderate its comments.',
        'No page selected',
        ContextualFeedbackSeverity::INFO,
        false,
      );
      return $this->moduleTemplate->renderResponse('CommentModeration/List');
    }

    /** @var SiteInterface $site */
    $site = $this->request->getAttribute('site');
    $languages = $site->getAvailableLanguages(
      $this->getBackendUser(),
      true,
      $this->pageId,
    );
    $language ??= (int)$this->moduleData->get('language', 0);
    if (!isset($languages[$language])) {
      $language = (int)array_key_first($languages);
    }
    $this->moduleData->set('language', $language);
    $this->getBackendUser()->pushModuleData(
      $this->moduleData->getModuleIdentifier(),
      $this->moduleData->toArray(),
    );
    $this->addLanguageSelector($languages, $language);

    $languageAspect = $language === self::ALL_LANGUAGES
      ? null
      : LanguageAspectFactory::createFromSiteLanguage($languages[$language]);
    $this->moduleTemplate->assign(
      'comments',
      $this->commentRepository->findForModeration($languageAspect),
    );
    return $this->moduleTemplate->renderResponse('CommentModeration/List');
  }

  /**
   * @param array<int, SiteLanguage> $languages
   */
  protected function addLanguageSelector(array $languages, int $active): void
  {
    $selector = $this->componentFactory->createDropDownButton()
      ->setLabel('Language')
      ->setShowLabelText(true)
      ->setShowActiveLabelText(true);
    foreach ($languages as $languageId => $siteLanguage) {
      $selector->addItem(
        $this->componentFactory->createDropDownRadio()
          // Keeps the selected page
          ->setHref(
            $this->uriBuilder->reset()->uriFor('list', ['language' => $languageId]),
          )
          ->setLabel($siteLanguage->getTitle())
          ->setIcon(
            $this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()),
          )
          ->setActive($languageId === $active),
      );
    }
    $this->moduleTemplate->getDocHeaderComponent()->setLanguageSelector($selector);
  }

  public function answerAction(
    Comment $comment,
    #[IgnoreValidation]
    ?Comment $answer = null,
  ): ResponseInterface {
    $this->moduleTemplate->assignMultiple([
      'comment' => $comment,
      'answer' => $answer ?? new Comment(),
    ]);
    return $this->moduleTemplate->renderResponse('CommentModeration/Answer');
  }

  protected function initializeCreateAnswerAction(): void
  {
    $this->assertAllowedHttpMethod($this->request, 'POST');
  }

  public function createAnswerAction(
    Comment $comment,
    Comment $answer,
  ): ResponseInterface {
    if (!$this->mayAnswer($comment)) {
      $this->addFlashMessage(
        'You are not allowed to add comments on this page.',
        'Answer not saved',
        ContextualFeedbackSeverity::ERROR,
      );
      return $this->redirect('list');
    }

    $answer->setReplyTo($comment);
    $answer->setAuthor((string)$this->getBackendUser()->getUserName());
    // Otherwise the first storagePid, possibly a parent folder
    $answer->setPid((int)$comment->getPid());
    $this->commentRepository->add($answer);

    $this->addFlashMessage('The answer has been saved.');
    return $this->redirect('list');
  }

  protected function mayAnswer(Comment $comment): bool
  {
    $backendUser = $this->getBackendUser();
    $page = BackendUtility::getRecord('pages', (int)$comment->getPid());
    return $backendUser->check('tables_modify', self::TABLE)
      && $page !== null
      && $backendUser->doesUserHaveAccess($page, Permission::CONTENT_EDIT);
  }

  protected function getBackendUser(): BackendUserAuthentication
  {
    return $GLOBALS['BE_USER'];
  }
}
Copied!

There is no need to pass the page on. Links, forms and redirects that Extbase builds in a backend module keep the id of the current request, so the moderator stays on the selected page while working in the module. Only a module that passes its own values in id loses that, see The id parameter in an Extbase backend module.

Extension-wide TypoScript overrides the selected page 

The selected page is only the fallback. A storagePid in the module TypoScript wins on every request, whichever page the moderator selected. Selecting a page does not store anything, it only sends the id.

This matters as soon as an extension has more than one module. Suppose the conferences module sets its storagePid in module.tx_myextension , the scope shared by every module of the extension. A moderation module would inherit that storagePid and list the comments of folder 42, wherever the moderator clicks. One way out is to remove the value on the key of the moderation module:

EXT:my_extension/ext_localconf.php
<?php

declare(strict_types=1);

use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;

defined('TYPO3') or die();

ExtensionManagementUtility::addTypoScriptSetup('
  module.tx_myextension.persistence {
    storagePid = 42
    recursive = 1
  }
  # Fall back to the page selected in the page tree
  module.tx_myextension_my_extension_moderation.persistence {
    storagePid = __UNSET
  }
');
Copied!

__UNSET is not TypoScript syntax. It is a value that removes a key when the configuration of the module is merged over the shared scope. The TypoScript operator > does not work here: the key it would remove does not exist below the module key, and the shared value is merged in afterwards regardless. Applied to module.tx_myextension , it would remove the storagePid of the conferences module as well.

An inherited recursive = 1 stays and then starts at the selected page. That can suit moderation: a moderator who selects the site root sees the comments in the folders directly below it, see Searching subpages of the storagePid.

The other way round works too, and is easier to follow when only one module needs a fixed storage folder: leave the shared scope empty and set the storagePid only on the module that needs it, in module.tx_myextension_my_extension_conferences .

When no page is selected in the page tree 

A moderator who opens the module for the first time has not selected a page yet. Extbase would then search page 0, which usually holds no comments. Instead of an empty list, the module could display a hint as a flash message:

EXT:my_extension/Classes/Controller/CommentModerationController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Comment;
use MyVendor\MyExtension\Domain\Repository\CommentRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Http\AllowedMethodsTrait;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Extbase\Attribute\IgnoreValidation;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class CommentModerationController extends ActionController
{
  use AllowedMethodsTrait;

  protected const ALL_LANGUAGES = -1;
  protected const TABLE = 'tx_myextension_domain_model_comment';

  protected ?ModuleData $moduleData = null;
  protected ?ModuleTemplate $moduleTemplate = null;
  protected int $pageId = 0;

  public function __construct(
    protected readonly ModuleTemplateFactory $moduleTemplateFactory,
    protected readonly ComponentFactory $componentFactory,
    protected readonly IconFactory $iconFactory,
    protected readonly CommentRepository $commentRepository,
  ) {}

  #[\Override]
  protected function initializeAction(): void
  {
    $this->moduleData = $this->request->getAttribute('moduleData');
    $this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
    $this->pageId = (int)($this->request->getQueryParams()['id'] ?? 0);
  }

  public function listAction(?int $language = null): ResponseInterface
  {
    if ($this->pageId === 0) {
      $this->moduleTemplate->addFlashMessage(
        'Select a page in the page tree to moderate its comments.',
        'No page selected',
        ContextualFeedbackSeverity::INFO,
        false,
      );
      return $this->moduleTemplate->renderResponse('CommentModeration/List');
    }

    /** @var SiteInterface $site */
    $site = $this->request->getAttribute('site');
    $languages = $site->getAvailableLanguages(
      $this->getBackendUser(),
      true,
      $this->pageId,
    );
    $language ??= (int)$this->moduleData->get('language', 0);
    if (!isset($languages[$language])) {
      $language = (int)array_key_first($languages);
    }
    $this->moduleData->set('language', $language);
    $this->getBackendUser()->pushModuleData(
      $this->moduleData->getModuleIdentifier(),
      $this->moduleData->toArray(),
    );
    $this->addLanguageSelector($languages, $language);

    $languageAspect = $language === self::ALL_LANGUAGES
      ? null
      : LanguageAspectFactory::createFromSiteLanguage($languages[$language]);
    $this->moduleTemplate->assign(
      'comments',
      $this->commentRepository->findForModeration($languageAspect),
    );
    return $this->moduleTemplate->renderResponse('CommentModeration/List');
  }

  /**
   * @param array<int, SiteLanguage> $languages
   */
  protected function addLanguageSelector(array $languages, int $active): void
  {
    $selector = $this->componentFactory->createDropDownButton()
      ->setLabel('Language')
      ->setShowLabelText(true)
      ->setShowActiveLabelText(true);
    foreach ($languages as $languageId => $siteLanguage) {
      $selector->addItem(
        $this->componentFactory->createDropDownRadio()
          // Keeps the selected page
          ->setHref(
            $this->uriBuilder->reset()->uriFor('list', ['language' => $languageId]),
          )
          ->setLabel($siteLanguage->getTitle())
          ->setIcon(
            $this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()),
          )
          ->setActive($languageId === $active),
      );
    }
    $this->moduleTemplate->getDocHeaderComponent()->setLanguageSelector($selector);
  }

  public function answerAction(
    Comment $comment,
    #[IgnoreValidation]
    ?Comment $answer = null,
  ): ResponseInterface {
    $this->moduleTemplate->assignMultiple([
      'comment' => $comment,
      'answer' => $answer ?? new Comment(),
    ]);
    return $this->moduleTemplate->renderResponse('CommentModeration/Answer');
  }

  protected function initializeCreateAnswerAction(): void
  {
    $this->assertAllowedHttpMethod($this->request, 'POST');
  }

  public function createAnswerAction(
    Comment $comment,
    Comment $answer,
  ): ResponseInterface {
    if (!$this->mayAnswer($comment)) {
      $this->addFlashMessage(
        'You are not allowed to add comments on this page.',
        'Answer not saved',
        ContextualFeedbackSeverity::ERROR,
      );
      return $this->redirect('list');
    }

    $answer->setReplyTo($comment);
    $answer->setAuthor((string)$this->getBackendUser()->getUserName());
    // Otherwise the first storagePid, possibly a parent folder
    $answer->setPid((int)$comment->getPid());
    $this->commentRepository->add($answer);

    $this->addFlashMessage('The answer has been saved.');
    return $this->redirect('list');
  }

  protected function mayAnswer(Comment $comment): bool
  {
    $backendUser = $this->getBackendUser();
    $page = BackendUtility::getRecord('pages', (int)$comment->getPid());
    return $backendUser->check('tables_modify', self::TABLE)
      && $page !== null
      && $backendUser->doesUserHaveAccess($page, Permission::CONTENT_EDIT);
  }

  protected function getBackendUser(): BackendUserAuthentication
  {
    return $GLOBALS['BE_USER'];
  }
}
Copied!

Listing hidden records in an Extbase backend module 

TYPO3 treats records differently depending on where they are shown. The frontend respects all restrictions and only shows records that are valid for the visitor: not hidden, within their start and end time, and visible to the visitor's user groups. The backend is where records are managed, so Core's backend modules show everything except deleted records.

Extbase follows this. In a backend module, its queries ignore the enable fields by default: they return hidden records, and records outside their start and end time, as the other backend modules do.

Changed in version 14.3.6

Extbase queries in the backend ignore the enable fields by default. Before, they applied them and returned no hidden records.

A moderation list needs exactly that. Otherwise a comment would disappear from the list the moment the moderator hides it, and could not be shown again from the module. A repository method can still state it explicitly, and order the comments newest first, for example:

EXT:my_extension/Classes/Domain/Repository/CommentRepository.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Repository;

use MyVendor\MyExtension\Domain\Model\Comment;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Extbase\Persistence\QueryInterface;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;

class CommentRepository extends Repository
{
  /**
   * @param LanguageAspect|null $languageAspect null lists all languages
   * @return QueryResultInterface<Comment>
   */
  public function findForModeration(
    ?LanguageAspect $languageAspect,
  ): QueryResultInterface {
    $query = $this->createQuery();
    $querySettings = $query->getQuerySettings();
    // Keep hidden comments in the list, so they can be shown again
    $querySettings->setIgnoreEnableFields(true);
    if ($languageAspect === null) {
      // Every comment once, in its own language
      $querySettings->setRespectSysLanguage(false);
    } else {
      $querySettings->setLanguageAspect($languageAspect);
    }
    // The model has no crdate property, the column is used directly
    $query->setOrderings(['crdate' => QueryInterface::ORDER_DESCENDING]);

    return $query->execute();
  }
}
Copied!

In a backend module, setIgnoreEnableFields(true) repeats the default. Setting it anyway keeps the list independent of where the method is called: in a command or a middleware, Extbase applies the enable fields, and on a TYPO3 version before 14.3.6 it does so in the backend too. The query settings are described in Query settings. Ordering by crdate works without a crdate property in the model: Extbase uses the column of the same name.

The list action can then hand the result to the template:

EXT:my_extension/Classes/Controller/CommentModerationController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Comment;
use MyVendor\MyExtension\Domain\Repository\CommentRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Http\AllowedMethodsTrait;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Extbase\Attribute\IgnoreValidation;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class CommentModerationController extends ActionController
{
  use AllowedMethodsTrait;

  protected const ALL_LANGUAGES = -1;
  protected const TABLE = 'tx_myextension_domain_model_comment';

  protected ?ModuleData $moduleData = null;
  protected ?ModuleTemplate $moduleTemplate = null;
  protected int $pageId = 0;

  public function __construct(
    protected readonly ModuleTemplateFactory $moduleTemplateFactory,
    protected readonly ComponentFactory $componentFactory,
    protected readonly IconFactory $iconFactory,
    protected readonly CommentRepository $commentRepository,
  ) {}

  #[\Override]
  protected function initializeAction(): void
  {
    $this->moduleData = $this->request->getAttribute('moduleData');
    $this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
    $this->pageId = (int)($this->request->getQueryParams()['id'] ?? 0);
  }

  public function listAction(?int $language = null): ResponseInterface
  {
    if ($this->pageId === 0) {
      $this->moduleTemplate->addFlashMessage(
        'Select a page in the page tree to moderate its comments.',
        'No page selected',
        ContextualFeedbackSeverity::INFO,
        false,
      );
      return $this->moduleTemplate->renderResponse('CommentModeration/List');
    }

    /** @var SiteInterface $site */
    $site = $this->request->getAttribute('site');
    $languages = $site->getAvailableLanguages(
      $this->getBackendUser(),
      true,
      $this->pageId,
    );
    $language ??= (int)$this->moduleData->get('language', 0);
    if (!isset($languages[$language])) {
      $language = (int)array_key_first($languages);
    }
    $this->moduleData->set('language', $language);
    $this->getBackendUser()->pushModuleData(
      $this->moduleData->getModuleIdentifier(),
      $this->moduleData->toArray(),
    );
    $this->addLanguageSelector($languages, $language);

    $languageAspect = $language === self::ALL_LANGUAGES
      ? null
      : LanguageAspectFactory::createFromSiteLanguage($languages[$language]);
    $this->moduleTemplate->assign(
      'comments',
      $this->commentRepository->findForModeration($languageAspect),
    );
    return $this->moduleTemplate->renderResponse('CommentModeration/List');
  }

  /**
   * @param array<int, SiteLanguage> $languages
   */
  protected function addLanguageSelector(array $languages, int $active): void
  {
    $selector = $this->componentFactory->createDropDownButton()
      ->setLabel('Language')
      ->setShowLabelText(true)
      ->setShowActiveLabelText(true);
    foreach ($languages as $languageId => $siteLanguage) {
      $selector->addItem(
        $this->componentFactory->createDropDownRadio()
          // Keeps the selected page
          ->setHref(
            $this->uriBuilder->reset()->uriFor('list', ['language' => $languageId]),
          )
          ->setLabel($siteLanguage->getTitle())
          ->setIcon(
            $this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()),
          )
          ->setActive($languageId === $active),
      );
    }
    $this->moduleTemplate->getDocHeaderComponent()->setLanguageSelector($selector);
  }

  public function answerAction(
    Comment $comment,
    #[IgnoreValidation]
    ?Comment $answer = null,
  ): ResponseInterface {
    $this->moduleTemplate->assignMultiple([
      'comment' => $comment,
      'answer' => $answer ?? new Comment(),
    ]);
    return $this->moduleTemplate->renderResponse('CommentModeration/Answer');
  }

  protected function initializeCreateAnswerAction(): void
  {
    $this->assertAllowedHttpMethod($this->request, 'POST');
  }

  public function createAnswerAction(
    Comment $comment,
    Comment $answer,
  ): ResponseInterface {
    if (!$this->mayAnswer($comment)) {
      $this->addFlashMessage(
        'You are not allowed to add comments on this page.',
        'Answer not saved',
        ContextualFeedbackSeverity::ERROR,
      );
      return $this->redirect('list');
    }

    $answer->setReplyTo($comment);
    $answer->setAuthor((string)$this->getBackendUser()->getUserName());
    // Otherwise the first storagePid, possibly a parent folder
    $answer->setPid((int)$comment->getPid());
    $this->commentRepository->add($answer);

    $this->addFlashMessage('The answer has been saved.');
    return $this->redirect('list');
  }

  protected function mayAnswer(Comment $comment): bool
  {
    $backendUser = $this->getBackendUser();
    $page = BackendUtility::getRecord('pages', (int)$comment->getPid());
    return $backendUser->check('tables_modify', self::TABLE)
      && $page !== null
      && $backendUser->doesUserHaveAccess($page, Permission::CONTENT_EDIT);
  }

  protected function getBackendUser(): BackendUserAuthentication
  {
    return $GLOBALS['BE_USER'];
  }
}
Copied!

Choosing the language in an Extbase backend module with a page tree 

The site of the selected page knows its languages. A module can offer them in the language selector of the document header, together with "All languages", and list the comments of the chosen language:

EXT:my_extension/Classes/Controller/CommentModerationController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Comment;
use MyVendor\MyExtension\Domain\Repository\CommentRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Http\AllowedMethodsTrait;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Extbase\Attribute\IgnoreValidation;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class CommentModerationController extends ActionController
{
  use AllowedMethodsTrait;

  protected const ALL_LANGUAGES = -1;
  protected const TABLE = 'tx_myextension_domain_model_comment';

  protected ?ModuleData $moduleData = null;
  protected ?ModuleTemplate $moduleTemplate = null;
  protected int $pageId = 0;

  public function __construct(
    protected readonly ModuleTemplateFactory $moduleTemplateFactory,
    protected readonly ComponentFactory $componentFactory,
    protected readonly IconFactory $iconFactory,
    protected readonly CommentRepository $commentRepository,
  ) {}

  #[\Override]
  protected function initializeAction(): void
  {
    $this->moduleData = $this->request->getAttribute('moduleData');
    $this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
    $this->pageId = (int)($this->request->getQueryParams()['id'] ?? 0);
  }

  public function listAction(?int $language = null): ResponseInterface
  {
    if ($this->pageId === 0) {
      $this->moduleTemplate->addFlashMessage(
        'Select a page in the page tree to moderate its comments.',
        'No page selected',
        ContextualFeedbackSeverity::INFO,
        false,
      );
      return $this->moduleTemplate->renderResponse('CommentModeration/List');
    }

    /** @var SiteInterface $site */
    $site = $this->request->getAttribute('site');
    $languages = $site->getAvailableLanguages(
      $this->getBackendUser(),
      true,
      $this->pageId,
    );
    $language ??= (int)$this->moduleData->get('language', 0);
    if (!isset($languages[$language])) {
      $language = (int)array_key_first($languages);
    }
    $this->moduleData->set('language', $language);
    $this->getBackendUser()->pushModuleData(
      $this->moduleData->getModuleIdentifier(),
      $this->moduleData->toArray(),
    );
    $this->addLanguageSelector($languages, $language);

    $languageAspect = $language === self::ALL_LANGUAGES
      ? null
      : LanguageAspectFactory::createFromSiteLanguage($languages[$language]);
    $this->moduleTemplate->assign(
      'comments',
      $this->commentRepository->findForModeration($languageAspect),
    );
    return $this->moduleTemplate->renderResponse('CommentModeration/List');
  }

  /**
   * @param array<int, SiteLanguage> $languages
   */
  protected function addLanguageSelector(array $languages, int $active): void
  {
    $selector = $this->componentFactory->createDropDownButton()
      ->setLabel('Language')
      ->setShowLabelText(true)
      ->setShowActiveLabelText(true);
    foreach ($languages as $languageId => $siteLanguage) {
      $selector->addItem(
        $this->componentFactory->createDropDownRadio()
          // Keeps the selected page
          ->setHref(
            $this->uriBuilder->reset()->uriFor('list', ['language' => $languageId]),
          )
          ->setLabel($siteLanguage->getTitle())
          ->setIcon(
            $this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()),
          )
          ->setActive($languageId === $active),
      );
    }
    $this->moduleTemplate->getDocHeaderComponent()->setLanguageSelector($selector);
  }

  public function answerAction(
    Comment $comment,
    #[IgnoreValidation]
    ?Comment $answer = null,
  ): ResponseInterface {
    $this->moduleTemplate->assignMultiple([
      'comment' => $comment,
      'answer' => $answer ?? new Comment(),
    ]);
    return $this->moduleTemplate->renderResponse('CommentModeration/Answer');
  }

  protected function initializeCreateAnswerAction(): void
  {
    $this->assertAllowedHttpMethod($this->request, 'POST');
  }

  public function createAnswerAction(
    Comment $comment,
    Comment $answer,
  ): ResponseInterface {
    if (!$this->mayAnswer($comment)) {
      $this->addFlashMessage(
        'You are not allowed to add comments on this page.',
        'Answer not saved',
        ContextualFeedbackSeverity::ERROR,
      );
      return $this->redirect('list');
    }

    $answer->setReplyTo($comment);
    $answer->setAuthor((string)$this->getBackendUser()->getUserName());
    // Otherwise the first storagePid, possibly a parent folder
    $answer->setPid((int)$comment->getPid());
    $this->commentRepository->add($answer);

    $this->addFlashMessage('The answer has been saved.');
    return $this->redirect('list');
  }

  protected function mayAnswer(Comment $comment): bool
  {
    $backendUser = $this->getBackendUser();
    $page = BackendUtility::getRecord('pages', (int)$comment->getPid());
    return $backendUser->check('tables_modify', self::TABLE)
      && $page !== null
      && $backendUser->doesUserHaveAccess($page, Permission::CONTENT_EDIT);
  }

  protected function getBackendUser(): BackendUserAuthentication
  {
    return $GLOBALS['BE_USER'];
  }
}
Copied!

What is Extbase-specific here:

  • The chosen language can arrive as an argument of the list action, here language. Extbase passes the arguments of a backend module without a plugin namespace, so the parameter is called language in the URL as well.
  • uriFor() can build the links of the selector. Like every Extbase link in a module, it keeps the selected page and adds the route token the module requires.
  • Keeping the choice in the module data lets the moderator find the same language after selecting another page. The module data is described in Keeping the filters of an Extbase backend module in ModuleData.
  • LanguageAspectFactory::createFromSiteLanguage() creates the language aspect the frontend would use for the chosen language, with its fallback configuration, see Page-bound modules (with page tree).

The second argument of getAvailableLanguages() adds "All languages" with the id -1, provided the moderator has access to it. For this choice, the example passes no language aspect to the repository, which drops the language restriction with setRespectSysLanguage(false) . Each comment is then listed once, in its own language.

The dropdown itself is not Extbase-specific. It is described in Adding module actions and a language selector to the module header.

Hiding records from an Extbase backend module with Core's visibility toggle 

The record list of the Content > Records module hides and shows records with a button in each row. A module does not have to build this itself. It can reuse that button: render the same markup, load the same JavaScript, and let Core do the rest. This is the recommended way, because Core then takes care of permissions and of writing the record.

EXT:my_extension/Resources/Private/Templates/CommentModeration/List.fluid.html
<html
  xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
  xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
  data-namespace-typo3-fluid="true">

<f:layout name="Module" />

<f:section name="Content">
  <f:asset.module identifier="@typo3/backend/recordlist.js" />

  <h1>Comment moderation</h1>
  <f:flashMessages />

  <table class="table table-striped">
    <f:for each="{comments}" as="comment">
      <tr data-uid="{comment.uid}" data-table="tx_myextension_domain_model_comment">
        <td class="col-icon">
          <core:iconForRecord
            table="tx_myextension_domain_model_comment"
            row="{uid: comment.uid, hidden: comment.hidden}"
          />
        </td>
        <td>{comment.author}</td>
        <td>{comment.text}</td>
        <td>
          <div class="btn-group">
            <button
              type="button"
              class="btn btn-default"
              title="{f:if(condition: comment.hidden, then: 'Show', else: 'Hide')}"
              data-datahandler-action="visibility"
              data-datahandler-status="{f:if(condition: comment.hidden, then: 'hidden', else: 'visible')}"
              data-datahandler-visible-label="Hide"
              data-datahandler-hidden-label="Show"
            >
              <core:icon identifier="{f:if(
                condition: comment.hidden,
                then: 'actions-edit-unhide',
                else: 'actions-edit-hide')}"
              />
            </button>
            <f:link.action
              action="answer"
              arguments="{comment: comment}"
              class="btn btn-default"
            >
              Answer
            </f:link.action>
          </div>
        </td>
      </tr>
    </f:for>
  </table>
</f:section>

</html>
Copied!

The JavaScript module @typo3/backend/recordlist.js reacts to every button with data-datahandler-action="visibility" . It expects this markup:

  • The row is a <tr> with data-uid and data-table . The script takes the record from the row, not from the button.
  • data-datahandler-status is visible or hidden, the state the record has when the list is rendered.
  • The button contains an icon, rendered with <core:icon> . The script replaces it with a spinner while the request runs, and with the icon for the new state afterwards.
  • The row contains a record icon in a cell with the class col-icon, rendered with <core:iconForRecord> . The script replaces it with the icon for the new state, and fails without it.
  • data-datahandler-visible-label and data-datahandler-hidden-label become the title of the button in the respective state.

A click sends the table, the uid and the requested state to the backend. Core checks that the table has a field for hiding records, that the moderator may change records of the table, and that the moderator may see the page the record is stored on. It then writes through DataHandler, with history, hooks and cache clearing. The row stays in the list, only its state changes.

The module writes nothing itself. All the table needs is a hidden field, configured in enablecolumns.

Answering a comment in an Extbase backend module action 

An answer is not an edit of the comment. It can be modelled as a new comment that refers to the answered one, written by the moderator. This is one of the cases in which an Extbase module may write records itself rather than handing them to FormEngine, see When an Extbase module writes records itself.

For this, the example uses a Comment model with only the fields the answer form and the list need:

EXT:my_extension/Classes/Domain/Model/Comment.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\Validate;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Comment extends AbstractEntity
{
  protected string $author = '';

  #[Validate(validator: 'NotEmpty')]
  protected string $text = '';

  // No setter: Core's visibility toggle writes it
  protected bool $hidden = false;

  protected ?Comment $replyTo = null;

  public function getAuthor(): string
  {
    return $this->author;
  }

  public function setAuthor(string $author): void
  {
    $this->author = $author;
  }

  public function getText(): string
  {
    return $this->text;
  }

  public function setText(string $text): void
  {
    $this->text = $text;
  }

  public function isHidden(): bool
  {
    return $this->hidden;
  }

  public function getReplyTo(): ?Comment
  {
    return $this->replyTo;
  }

  public function setReplyTo(?Comment $replyTo): void
  {
    $this->replyTo = $replyTo;
  }
}
Copied!

The list can link every comment to the answer form:

EXT:my_extension/Resources/Private/Templates/CommentModeration/List.fluid.html
<html
  xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
  xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
  data-namespace-typo3-fluid="true">

<f:layout name="Module" />

<f:section name="Content">
  <f:asset.module identifier="@typo3/backend/recordlist.js" />

  <h1>Comment moderation</h1>
  <f:flashMessages />

  <table class="table table-striped">
    <f:for each="{comments}" as="comment">
      <tr data-uid="{comment.uid}" data-table="tx_myextension_domain_model_comment">
        <td class="col-icon">
          <core:iconForRecord
            table="tx_myextension_domain_model_comment"
            row="{uid: comment.uid, hidden: comment.hidden}"
          />
        </td>
        <td>{comment.author}</td>
        <td>{comment.text}</td>
        <td>
          <div class="btn-group">
            <button
              type="button"
              class="btn btn-default"
              title="{f:if(condition: comment.hidden, then: 'Show', else: 'Hide')}"
              data-datahandler-action="visibility"
              data-datahandler-status="{f:if(condition: comment.hidden, then: 'hidden', else: 'visible')}"
              data-datahandler-visible-label="Hide"
              data-datahandler-hidden-label="Show"
            >
              <core:icon identifier="{f:if(
                condition: comment.hidden,
                then: 'actions-edit-unhide',
                else: 'actions-edit-hide')}"
              />
            </button>
            <f:link.action
              action="answer"
              arguments="{comment: comment}"
              class="btn btn-default"
            >
              Answer
            </f:link.action>
          </div>
        </td>
      </tr>
    </f:for>
  </table>
</f:section>

</html>
Copied!

Property mapping loads the comment of the action argument with the same default, so hidden comments can be answered as well. A module that should not allow this could render the link only for visible comments.

The two actions can follow the usual pattern of a form in Extbase, see Validation. Here, answerAction() shows the form and createAnswerAction() stores the answer:

EXT:my_extension/Classes/Controller/CommentModerationController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Comment;
use MyVendor\MyExtension\Domain\Repository\CommentRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Http\AllowedMethodsTrait;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Extbase\Attribute\IgnoreValidation;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class CommentModerationController extends ActionController
{
  use AllowedMethodsTrait;

  protected const ALL_LANGUAGES = -1;
  protected const TABLE = 'tx_myextension_domain_model_comment';

  protected ?ModuleData $moduleData = null;
  protected ?ModuleTemplate $moduleTemplate = null;
  protected int $pageId = 0;

  public function __construct(
    protected readonly ModuleTemplateFactory $moduleTemplateFactory,
    protected readonly ComponentFactory $componentFactory,
    protected readonly IconFactory $iconFactory,
    protected readonly CommentRepository $commentRepository,
  ) {}

  #[\Override]
  protected function initializeAction(): void
  {
    $this->moduleData = $this->request->getAttribute('moduleData');
    $this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
    $this->pageId = (int)($this->request->getQueryParams()['id'] ?? 0);
  }

  public function listAction(?int $language = null): ResponseInterface
  {
    if ($this->pageId === 0) {
      $this->moduleTemplate->addFlashMessage(
        'Select a page in the page tree to moderate its comments.',
        'No page selected',
        ContextualFeedbackSeverity::INFO,
        false,
      );
      return $this->moduleTemplate->renderResponse('CommentModeration/List');
    }

    /** @var SiteInterface $site */
    $site = $this->request->getAttribute('site');
    $languages = $site->getAvailableLanguages(
      $this->getBackendUser(),
      true,
      $this->pageId,
    );
    $language ??= (int)$this->moduleData->get('language', 0);
    if (!isset($languages[$language])) {
      $language = (int)array_key_first($languages);
    }
    $this->moduleData->set('language', $language);
    $this->getBackendUser()->pushModuleData(
      $this->moduleData->getModuleIdentifier(),
      $this->moduleData->toArray(),
    );
    $this->addLanguageSelector($languages, $language);

    $languageAspect = $language === self::ALL_LANGUAGES
      ? null
      : LanguageAspectFactory::createFromSiteLanguage($languages[$language]);
    $this->moduleTemplate->assign(
      'comments',
      $this->commentRepository->findForModeration($languageAspect),
    );
    return $this->moduleTemplate->renderResponse('CommentModeration/List');
  }

  /**
   * @param array<int, SiteLanguage> $languages
   */
  protected function addLanguageSelector(array $languages, int $active): void
  {
    $selector = $this->componentFactory->createDropDownButton()
      ->setLabel('Language')
      ->setShowLabelText(true)
      ->setShowActiveLabelText(true);
    foreach ($languages as $languageId => $siteLanguage) {
      $selector->addItem(
        $this->componentFactory->createDropDownRadio()
          // Keeps the selected page
          ->setHref(
            $this->uriBuilder->reset()->uriFor('list', ['language' => $languageId]),
          )
          ->setLabel($siteLanguage->getTitle())
          ->setIcon(
            $this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()),
          )
          ->setActive($languageId === $active),
      );
    }
    $this->moduleTemplate->getDocHeaderComponent()->setLanguageSelector($selector);
  }

  public function answerAction(
    Comment $comment,
    #[IgnoreValidation]
    ?Comment $answer = null,
  ): ResponseInterface {
    $this->moduleTemplate->assignMultiple([
      'comment' => $comment,
      'answer' => $answer ?? new Comment(),
    ]);
    return $this->moduleTemplate->renderResponse('CommentModeration/Answer');
  }

  protected function initializeCreateAnswerAction(): void
  {
    $this->assertAllowedHttpMethod($this->request, 'POST');
  }

  public function createAnswerAction(
    Comment $comment,
    Comment $answer,
  ): ResponseInterface {
    if (!$this->mayAnswer($comment)) {
      $this->addFlashMessage(
        'You are not allowed to add comments on this page.',
        'Answer not saved',
        ContextualFeedbackSeverity::ERROR,
      );
      return $this->redirect('list');
    }

    $answer->setReplyTo($comment);
    $answer->setAuthor((string)$this->getBackendUser()->getUserName());
    // Otherwise the first storagePid, possibly a parent folder
    $answer->setPid((int)$comment->getPid());
    $this->commentRepository->add($answer);

    $this->addFlashMessage('The answer has been saved.');
    return $this->redirect('list');
  }

  protected function mayAnswer(Comment $comment): bool
  {
    $backendUser = $this->getBackendUser();
    $page = BackendUtility::getRecord('pages', (int)$comment->getPid());
    return $backendUser->check('tables_modify', self::TABLE)
      && $page !== null
      && $backendUser->doesUserHaveAccess($page, Permission::CONTENT_EDIT);
  }

  protected function getBackendUser(): BackendUserAuthentication
  {
    return $GLOBALS['BE_USER'];
  }
}
Copied!

A module that writes records itself takes over what Core would do otherwise. The example covers these points:

  • Checking the permissions. mayAnswer() requires that the moderator may change records of the comment table and edit the content of the page the comment is stored on.
  • Setting the page of the answer. Without setPid() , Extbase stores a new record on the first storagePid. With recursive , that is the selected page, which can be the parent of the comment's folder.
  • Protecting against cross-site requests. Every backend URL carries a route token, and Extbase builds its form URLs through the backend router, so a forged request has no valid token. It is recommended to also accept the answer only as a POST request. The example does this in initializeCreateAnswerAction() with \TYPO3\CMS\Core\Http\AllowedMethodsTrait , as Core's backend user module does. See Security considerations.

What such an action does not have is what DataHandler adds: no history entry and no DataHandler hooks for the answer. In this example the answer is stored in the default language, whichever language the answered comment has.

The answer form can be an ordinary Extbase form:

EXT:my_extension/Resources/Private/Templates/CommentModeration/Answer.fluid.html
<html
  xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
  data-namespace-typo3-fluid="true">

<f:layout name="Module" />

<f:section name="Content">
  <h1>Answer a comment</h1>

  <blockquote class="blockquote">
    <p>{comment.text}</p>
    <footer class="blockquote-footer">{comment.author}</footer>
  </blockquote>

  <f:form action="createAnswer" name="answer" object="{answer}" arguments="{comment: comment}">
    <f:form.validationResults for="answer.text">
      <f:for each="{validationResults.errors}" as="error">
        <div class="alert alert-danger">{error.message}</div>
      </f:for>
    </f:form.validationResults>

    <div class="form-group">
      <label for="answer-text" class="form-label">Answer</label>
      <f:form.textarea property="text" id="answer-text" class="form-control" rows="5" />
    </div>

    <f:form.submit value="Save answer" class="btn btn-primary" />
    <f:link.action action="list" class="btn btn-default">Cancel</f:link.action>
  </f:form>
</f:section>

</html>
Copied!

The answer could go through FormEngine instead: a <be:link.newRecord> link with defaultValues that prefill the reference to the comment and the page, see Creating records from an Extbase backend module. That way the module gets history and hooks, and the moderator gets the full record form instead of a single field. Which one fits depends on how much of the record the moderator should see.

Messages from addFlashMessage() of the controller go to the queue of the Extbase module and are rendered by <f:flashMessages /> in the list template, see Flash messages.

Next steps for the module with a page tree 

The two example modules of this chapter show both situations an Extbase backend module can be in: alone with its configuration, or supplied by the page the editor selected. The overview of the chapter is in Building backend modules with Extbase.

Commands and middlewares have no page and no backend module around them. How Extbase behaves there is described in Extbase without a frontend context.