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.
Table of contents
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:
<?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',
],
],
],
];
A module below another main module can add the page tree with
navigationComponent set to
'@typo3/. Core's link validator is
registered this way.
The example registers three actions: list shows the comments, answer
displays the answer form and create 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:
- The storagePid: without a configured storagePid, Extbase searches the selected page, see The storagePid resolution chain in a backend module.
- The site: the
siterequest attribute holds the site of the selected page. - The TypoScript of the page: a module with page context reads the TypoScript of the selected page, see Which TypoScript an Extbase backend module sees.
A controller can read the page uid once, in
initialize, and
use it in every action:
<?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'];
}
}
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., 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:
<?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
}
');
__ 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., 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..
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:
<?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'];
}
}
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:
<?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();
}
}
In a backend module,
set 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:
<?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'];
}
}
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:
<?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'];
}
}
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 calledlanguagein the URL as well. urican 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.For () - 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.
Languagecreates the language aspect the frontend would use for the chosen language, with its fallback configuration, see Page-bound modules (with page tree).Aspect Factory:: create From Site Language ()
The second argument of
get 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
set. 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.
<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>
The JavaScript module
@typo3/ reacts to every
button with
data-. It expects this
markup:
- The row is a
<tr>withdata-anduid data-. The script takes the record from the row, not from the button.table data-isdatahandler- status visibleorhidden, the state the record has when the list is rendered.- The button contains an icon, rendered with
<core:. The script replaces it with a spinner while the request runs, and with the icon for the new state afterwards.icon> - The row contains a record icon in a cell with the class
col-, rendered withicon <core:. The script replaces it with the icon for the new state, and fails without it.icon For Record> data-anddatahandler- visible- label data-become the title of the button in the respective state.datahandler- hidden- label
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:
<?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;
}
}
The list can link every comment to the answer form:
<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>
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,
answer shows the
form and
create stores the answer:
<?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'];
}
}
A module that writes records itself takes over what Core would do otherwise. The example covers these points:
- Checking the permissions.
mayrequires that the moderator may change records of the comment table and edit the content of the page the comment is stored on.Answer () - Setting the page of the answer. Without
set, Extbase stores a new record on the first storagePid. WithPid () 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
POSTrequest. The example does this ininitializewithCreate Answer Action () \TYPO3\, as Core's backend user module does. See Security considerations.CMS\ Core\ Http\ Allowed Methods Trait
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:
<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>
The answer could go through FormEngine instead: a
<be:
link with
default 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
add of the controller go to the queue of
the Extbase module and are rendered by
<f: 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.